mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(daemon): preserve audit evidence polarity
This commit is contained in:
@@ -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', [
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user