fix(daemon): preserve audit evidence polarity

This commit is contained in:
Brennan Benson
2026-07-30 16:52:53 -07:00
parent 9ec9233e4e
commit bbc976a032
6 changed files with 189 additions and 37 deletions
+10 -13
View File
@@ -123,14 +123,14 @@ export async function classifyDaemonAuditFailure(
context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [],
options.additionalEvidenceSources ?? []
)
if (
options.endpointGoneProof &&
context.endpointKind === 'windows-named-pipe' &&
exactIncarnation
) {
const windowsNamedPipeMissing =
options.endpointGoneProof === 'windows_named_pipe_missing' &&
context.endpointKind === 'windows-named-pipe'
const observedEndpointState = windowsNamedPipeMissing ? 'missing' : endpointState
if (windowsNamedPipeMissing && exactIncarnation && processEvidence.state !== 'present') {
return {
state: 'gone',
reason: options.endpointGoneProof,
reason: 'windows_named_pipe_missing',
trigger,
evidenceSources,
context,
@@ -138,8 +138,8 @@ export async function classifyDaemonAuditFailure(
reachability,
inventoryAuthority: 'unavailable',
processLiveness: 'gone',
processReason: options.endpointGoneProof,
endpointState: 'missing',
processReason: 'windows_named_pipe_missing',
endpointState: observedEndpointState,
observedAtMs: Date.now()
}
}
@@ -155,7 +155,7 @@ export async function classifyDaemonAuditFailure(
inventoryAuthority: 'unavailable',
processLiveness: 'gone',
processReason: processEvidence.reason,
endpointState,
endpointState: observedEndpointState,
observedAtMs: Date.now()
}
}
@@ -170,7 +170,7 @@ export async function classifyDaemonAuditFailure(
inventoryAuthority: 'unavailable',
processLiveness: processEvidence.state,
processReason: processEvidence.reason,
endpointState,
endpointState: observedEndpointState,
observedAtMs: Date.now()
}
}
@@ -192,9 +192,6 @@ async function inspectDaemonEndpointState(
function reachabilityForTrigger(
trigger: DaemonAuditFailureTrigger
): 'authenticated' | 'disconnected' | 'unknown' {
if (trigger === 'endpoint_identity_changed') {
return 'authenticated'
}
if (
trigger === 'transport_closed' ||
trigger === 'token_missing_after_authenticated_disconnect'
@@ -114,6 +114,18 @@ describe('daemon process identity evidence', () => {
).resolves.toMatchObject({ state: 'unknown', reason: 'permission_denied' })
})
it('keeps a missing proc stat unknown when signal zero still sees the pid', async () => {
await expect(
probeDaemonProcessIdentity(
exactIncarnation,
endpoint,
linuxDependencies({
readLinuxStat: async () => ({ status: 'missing' })
})
)
).resolves.toMatchObject({ state: 'unknown', reason: 'inspection_failed' })
})
it('keeps an unreadable command line unknown even when Linux start identity matches', async () => {
await expect(
probeDaemonProcessIdentity(
@@ -324,6 +336,36 @@ describe('daemon audit availability evidence', () => {
})
})
it('keeps contradictory Windows pipe and process evidence unknown', async () => {
const windowsContext: DaemonAuditContext = {
...context,
endpoint: '\\\\?\\pipe\\orca-daemon',
endpointKind: 'windows-named-pipe'
}
const dependencies = {
...auditClassifierDependencies,
probeProcessIdentity: async () => ({
state: 'present',
reason: 'windows_identity_match',
evidenceSources: ['windows_cim', 'endpoint_identity']
})
} satisfies DaemonAuditClassifierDependencies
await expect(
classifyDaemonAuditFailure(windowsContext, 'inventory_failed', exactIncarnation, {
additionalEvidenceSources: ['windows_named_pipe'],
endpointGoneProof: 'windows_named_pipe_missing',
dependencies
})
).resolves.toMatchObject({
state: 'unknown',
reason: 'inventory_failed',
processLiveness: 'present',
processReason: 'windows_identity_match',
endpointState: 'missing'
})
})
it('rejects named-pipe disappearance as a gone proof for Unix endpoints', async () => {
const observation = await classifyDaemonAuditFailure(
context,
@@ -66,7 +66,10 @@ async function probeLinuxProcess(
): Promise<DaemonProcessEvidence> {
const stat = await (dependencies.readLinuxStat ?? readLinuxStat)(exactIncarnation.identity.pid)
if (stat.status === 'missing') {
return gone('pid_missing', ['linux_proc_stat', 'endpoint_identity'], exactIncarnation)
return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [
'linux_proc_stat',
'process_signal'
])
}
if (stat.status === 'unavailable') {
return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [
+48 -1
View File
@@ -19,7 +19,7 @@ import type { SubprocessHandle } from './session'
import type { PendingOutputRecord } from './types'
import type { DaemonFileLog } from './daemon-file-log'
import type * as DaemonHealthModule from './daemon-health'
import { getDaemonSocketPath } from './daemon-spawner'
import { getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
@@ -1456,6 +1456,53 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
it('loads persisted Linux birth identity for audit observations', async () => {
adapter.dispose()
await server.shutdown()
const startedAtMs = 1_700_000_000_000
const launchNonce = 'launch-with-linux-identity'
server = new DaemonServer({
socketPath,
tokenPath,
startedAtMs,
launchNonce,
log: daemonLog,
spawnSubprocess: (opts) => {
lastSpawnOpts = opts
lastSubprocess = createMockSubprocess()
return lastSubprocess
}
})
await server.start()
const pidPath = join(dir, 'daemon.pid')
writeFileSync(
pidPath,
serializeDaemonPidFile({
pid: process.pid,
startedAtMs,
launchNonce,
linuxStartTicks: '4242',
bootId: 'boot-a'
})
)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
try {
adapter = new DaemonPtyAdapter({ socketPath, tokenPath, pidPath })
await expect(adapter.listProcesses()).resolves.toEqual([])
expect(adapter.getLastAuditObservation()?.exactIncarnation).toMatchObject({
linuxStartTicks: '4242',
bootId: 'boot-a'
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('reports the daemon session WSL owner', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+85 -20
View File
@@ -1,6 +1,7 @@
/* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */
import { basename } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { DaemonClient } from './client'
import {
getMacDaemonSystemResolverHealth,
@@ -152,6 +153,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
private socketPath: string
private tokenPath: string
private pidPath: string | null
private pidRecord: ParsedDaemonPid | null
private client: DaemonClient
private auditContext: DaemonAuditContext
private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null
@@ -248,6 +250,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
this.pidPath = opts.pidPath ?? null
this.pidRecord = readDaemonPidRecord(this.pidPath)
this.auditContext = {
protocolGeneration: this.protocolVersion,
provider: 'local-daemon',
@@ -1635,34 +1638,61 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (previous && sameEndpointIdentity(previous, current)) {
return
}
const previousExactIncarnation = this.exactDaemonIncarnation
const pidRecord = this.readMatchingPidRecord(current)
this.lastAuthenticatedIdentity = { ...current }
this.exactDaemonIncarnation = {
identity: { ...current },
...(pidRecord?.linuxStartTicks && pidRecord.bootId
? {
linuxStartTicks: pidRecord.linuxStartTicks,
bootId: pidRecord.bootId
}
: {})
}
this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord)
if (!previous) {
return
}
const event = { previous: { ...previous }, current: { ...current } }
notifyAuditListeners(this.identityChangeListeners, event)
this.observeAuditFailure('endpoint_identity_changed', previousExactIncarnation, [
'endpoint_identity'
])
}
private readMatchingPidRecord(identity: DaemonEndpointIdentity): ParsedDaemonPid | null {
private async resolveExactDaemonIncarnation(
exactIncarnation: ExactDaemonIncarnation | null
): Promise<ExactDaemonIncarnation | null> {
if (
!exactIncarnation ||
process.platform !== 'linux' ||
(exactIncarnation.linuxStartTicks && exactIncarnation.bootId)
) {
return exactIncarnation
}
const cachedIncarnation = exactDaemonIncarnationForPidRecord(
exactIncarnation.identity,
this.pidRecord
)
if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) {
return cachedIncarnation
}
const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity)
this.pidRecord = pidRecord ?? this.pidRecord
return pidRecord?.linuxStartTicks && pidRecord.bootId
? {
identity: { ...exactIncarnation.identity },
linuxStartTicks: pidRecord.linuxStartTicks,
bootId: pidRecord.bootId
}
: exactIncarnation
}
private cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void {
if (
exactIncarnation &&
this.lastAuthenticatedIdentity &&
sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity)
) {
this.exactDaemonIncarnation = exactIncarnation
}
}
private async readMatchingPidRecord(
identity: DaemonEndpointIdentity
): Promise<ParsedDaemonPid | null> {
if (!this.pidPath) {
return null
}
try {
const parsed = parseDaemonPidFile(readFileSync(this.pidPath, 'utf8'))
const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8'))
return parsed?.pid === identity.pid &&
parsed.startedAtMs === identity.startedAtMs &&
parsed.launchNonce === identity.launchNonce
@@ -1679,10 +1709,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
additionalEvidenceSources: readonly DaemonEvidenceSource[] = [],
endpointGoneProof?: 'windows_named_pipe_missing'
): void {
void classifyDaemonAuditFailure(this.auditContext, trigger, exactIncarnation, {
additionalEvidenceSources,
endpointGoneProof
})
void this.resolveExactDaemonIncarnation(exactIncarnation)
.then((resolvedIncarnation) => {
this.cacheExactDaemonIncarnation(resolvedIncarnation)
return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, {
additionalEvidenceSources,
endpointGoneProof
})
})
.then((observation) => this.publishAuditObservation(observation))
.catch(() => {})
}
@@ -2303,6 +2337,37 @@ function sameEndpointIdentity(
)
}
function exactDaemonIncarnationForPidRecord(
identity: DaemonEndpointIdentity,
pidRecord: ParsedDaemonPid | null
): ExactDaemonIncarnation {
return {
identity: { ...identity },
...(process.platform === 'linux' &&
pidRecord?.pid === identity.pid &&
pidRecord.startedAtMs === identity.startedAtMs &&
pidRecord.launchNonce === identity.launchNonce &&
pidRecord.linuxStartTicks &&
pidRecord.bootId
? {
linuxStartTicks: pidRecord.linuxStartTicks,
bootId: pidRecord.bootId
}
: {})
}
}
function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null {
if (!pidPath) {
return null
}
try {
return parseDaemonPidFile(readFileSync(pidPath, 'utf8'))
} catch {
return null
}
}
function removeListener<T>(listeners: T[], listener: T): void {
const index = listeners.indexOf(listener)
if (index !== -1) {
-2
View File
@@ -1,7 +1,6 @@
export const DAEMON_AUDIT_STATE_VALUES = ['present', 'gone', 'unknown'] as const
export const DAEMON_AUDIT_TRIGGER_VALUES = [
'endpoint_identity_changed',
'inventory_answered',
'inventory_failed',
'token_missing_after_authenticated_disconnect',
@@ -57,7 +56,6 @@ export const DAEMON_AUDIT_GONE_REASON_VALUES = [
export const DAEMON_AUDIT_REASON_VALUES = [
'authenticated_inventory',
'endpoint_identity_changed',
'inventory_failed',
'token_missing_after_authenticated_disconnect',
'transport_closed',