diff --git a/src/main/daemon/daemon-audit-classifier.ts b/src/main/daemon/daemon-audit-classifier.ts index 12b910acf15..b95408f1415 100644 --- a/src/main/daemon/daemon-audit-classifier.ts +++ b/src/main/daemon/daemon-audit-classifier.ts @@ -6,13 +6,12 @@ import { type DaemonProcessEvidence, type ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import type { + DaemonAuditFailureTrigger, + DaemonAuditGoneReason +} from '../../shared/daemon-audit-eligibility' -export type DaemonAuditTrigger = - | 'endpoint_identity_changed' - | 'inventory_answered' - | 'inventory_failed' - | 'token_missing_after_authenticated_disconnect' - | 'transport_closed' +export type { DaemonAuditTrigger } from '../../shared/daemon-audit-eligibility' export type DaemonAuditContext = { protocolGeneration: number @@ -23,10 +22,6 @@ export type DaemonAuditContext = { profileScope: string } -type DaemonAuditGoneReason = - | Extract['reason'] - | 'windows_named_pipe_missing' - export type DaemonAuditObservation = | { state: 'present' @@ -45,7 +40,7 @@ export type DaemonAuditObservation = | { state: 'gone' reason: DaemonAuditGoneReason - trigger: Exclude + trigger: DaemonAuditFailureTrigger evidenceSources: DaemonEvidenceSources context: DaemonAuditContext exactIncarnation: ExactDaemonIncarnation @@ -58,8 +53,8 @@ export type DaemonAuditObservation = } | { state: 'unknown' - reason: Exclude - trigger: Exclude + reason: DaemonAuditFailureTrigger + trigger: DaemonAuditFailureTrigger evidenceSources: DaemonEvidenceSources context: DaemonAuditContext exactIncarnation: ExactDaemonIncarnation | null @@ -75,6 +70,17 @@ export type DaemonAuditObservation = export type DaemonEndpointState = 'missing' | 'named-pipe' | 'non-socket' | 'socket' | 'unknown' +export type DaemonAuditClassifierDependencies = { + probeProcessIdentity?: typeof probeDaemonProcessIdentity + inspectEndpointState?: (context: DaemonAuditContext) => Promise +} + +export type DaemonAuditClassificationOptions = { + additionalEvidenceSources?: readonly DaemonEvidenceSource[] + endpointGoneProof?: 'windows_named_pipe_missing' + dependencies?: DaemonAuditClassifierDependencies +} + export function recordAuthenticatedInventory( context: DaemonAuditContext, exactIncarnation: ExactDaemonIncarnation | null @@ -97,28 +103,34 @@ export function recordAuthenticatedInventory( export async function classifyDaemonAuditFailure( context: DaemonAuditContext, - trigger: Exclude, + trigger: DaemonAuditFailureTrigger, exactIncarnation: ExactDaemonIncarnation | null, - additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], - endpointGoneProof?: 'windows_named_pipe_missing' + options: DaemonAuditClassificationOptions = {} ): Promise { + const probeProcessIdentity = + options.dependencies?.probeProcessIdentity ?? probeDaemonProcessIdentity + const inspectEndpoint = options.dependencies?.inspectEndpointState ?? inspectDaemonEndpointState const [processEvidence, endpointState] = await Promise.all([ - probeDaemonProcessIdentity(exactIncarnation, { + probeProcessIdentity(exactIncarnation, { socketPath: context.endpoint, tokenPath: context.tokenPath }), - inspectEndpointState(context) + inspectEndpoint(context) ]) const reachability = reachabilityForTrigger(trigger) const evidenceSources = combineEvidenceSources( processEvidence.evidenceSources, context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [], - additionalEvidenceSources + options.additionalEvidenceSources ?? [] ) - if (endpointGoneProof && exactIncarnation) { + if ( + options.endpointGoneProof && + context.endpointKind === 'windows-named-pipe' && + exactIncarnation + ) { return { state: 'gone', - reason: endpointGoneProof, + reason: options.endpointGoneProof, trigger, evidenceSources, context, @@ -126,7 +138,7 @@ export async function classifyDaemonAuditFailure( reachability, inventoryAuthority: 'unavailable', processLiveness: 'gone', - processReason: endpointGoneProof, + processReason: options.endpointGoneProof, endpointState: 'missing', observedAtMs: Date.now() } @@ -163,7 +175,9 @@ export async function classifyDaemonAuditFailure( } } -async function inspectEndpointState(context: DaemonAuditContext): Promise { +async function inspectDaemonEndpointState( + context: DaemonAuditContext +): Promise { if (context.endpointKind === 'windows-named-pipe') { return 'named-pipe' } @@ -176,7 +190,7 @@ async function inspectEndpointState(context: DaemonAuditContext): Promise + trigger: DaemonAuditFailureTrigger ): 'authenticated' | 'disconnected' | 'unknown' { if (trigger === 'endpoint_identity_changed') { return 'authenticated' diff --git a/src/main/daemon/daemon-incarnation-evidence-types.ts b/src/main/daemon/daemon-incarnation-evidence-types.ts index 4cb005caced..537a03fc191 100644 --- a/src/main/daemon/daemon-incarnation-evidence-types.ts +++ b/src/main/daemon/daemon-incarnation-evidence-types.ts @@ -1,32 +1,30 @@ import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { + DaemonAuditGoneReason, + DaemonEvidenceSource, + DaemonProcessGoneReason, + DaemonProcessPresentReason, + DaemonProcessUnknownReason +} from '../../shared/daemon-audit-eligibility' export const WINDOWS_CREATION_TIME_TOLERANCE_MS = 10_000 // Oracle contract validated 2026-07-29; omitted proofs remain unknown. export const DAEMON_GONE_PROOFS = { - linux: [ - 'pid_missing', - 'boot_identity_changed', - 'raw_start_ticks_mismatch', - 'matching_identity_zombie' - ], + linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'], darwin: ['pid_missing'], - win32: ['cim_process_missing', 'cim_creation_time_mismatch', 'named_pipe_missing'] -} as const + win32: ['windows_process_missing', 'windows_creation_time_mismatch', 'windows_named_pipe_missing'] +} as const satisfies Readonly< + Record<'linux' | 'darwin' | 'win32', readonly DaemonAuditGoneReason[]> +> -export type DaemonEvidenceSource = - | 'authenticated_inventory' - | 'boot_identity' - | 'endpoint_identity' - | 'endpoint_stat' - | 'linux_proc_stat' - | 'pid_record' - | 'process_command_line' - | 'process_signal' - | 'process_start_time' - | 'token_file' - | 'windows_cim' - | 'windows_named_pipe' +export type { + DaemonAuditGoneReason, + DaemonEvidenceSource, + DaemonProcessGoneReason, + DaemonProcessPresentReason, + DaemonProcessUnknownReason +} from '../../shared/daemon-audit-eligibility' export type DaemonEvidenceSources = readonly [DaemonEvidenceSource, ...DaemonEvidenceSource[]] @@ -39,34 +37,18 @@ export type ExactDaemonIncarnation = { export type DaemonProcessEvidence = | { state: 'present' - reason: 'linux_identity_match' | 'macos_identity_match' | 'windows_identity_match' + reason: DaemonProcessPresentReason evidenceSources: DaemonEvidenceSources } | { state: 'gone' - reason: - | 'linux_boot_changed' - | 'linux_start_ticks_mismatch' - | 'linux_zombie' - | 'pid_missing' - | 'windows_creation_time_mismatch' - | 'windows_process_missing' + reason: DaemonProcessGoneReason evidenceSources: DaemonEvidenceSources exactIncarnation: ExactDaemonIncarnation } | { state: 'unknown' - reason: - | 'command_line_mismatch' - | 'command_line_unavailable' - | 'exact_identity_unavailable' - | 'inspection_failed' - | 'linux_identity_incomplete' - | 'macos_start_time_mismatch' - | 'permission_denied' - | 'process_start_time_unavailable' - | 'windows_command_line_unavailable' - | 'windows_process_start_time_unavailable' + reason: DaemonProcessUnknownReason evidenceSources: DaemonEvidenceSources } diff --git a/src/main/daemon/daemon-incarnation-evidence.test.ts b/src/main/daemon/daemon-incarnation-evidence.test.ts index 3d9a07c204a..0a319a346d7 100644 --- a/src/main/daemon/daemon-incarnation-evidence.test.ts +++ b/src/main/daemon/daemon-incarnation-evidence.test.ts @@ -9,6 +9,7 @@ import { import { classifyDaemonAuditFailure, recordAuthenticatedInventory, + type DaemonAuditClassifierDependencies, type DaemonAuditContext } from './daemon-audit-classifier' @@ -18,6 +19,15 @@ const exactIncarnation: ExactDaemonIncarnation = { linuxStartTicks: '4242', bootId: 'boot-a' } +const auditClassifierDependencies = { + probeProcessIdentity: async () => ({ + state: 'unknown', + reason: 'inspection_failed', + evidenceSources: ['process_signal'] + }), + inspectEndpointState: async (context) => + context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'missing' +} satisfies DaemonAuditClassifierDependencies function linuxStat(state: string, startTicks: string): string { return `42 (orca daemon with spaces) ${[state, ...Array(18).fill('0'), startTicks].join(' ')}` @@ -40,14 +50,13 @@ function linuxDependencies( describe('daemon process identity evidence', () => { it('pins the dated conclusive-gone oracle contract', () => { expect(DAEMON_GONE_PROOFS).toEqual({ - linux: [ - 'pid_missing', - 'boot_identity_changed', - 'raw_start_ticks_mismatch', - 'matching_identity_zombie' - ], + linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'], darwin: ['pid_missing'], - win32: ['cim_process_missing', 'cim_creation_time_mismatch', 'named_pipe_missing'] + win32: [ + 'windows_process_missing', + 'windows_creation_time_mismatch', + 'windows_named_pipe_missing' + ] }) }) @@ -133,7 +142,7 @@ describe('daemon process identity evidence', () => { ).resolves.toMatchObject({ state: 'unknown', reason: 'exact_identity_unavailable' }) }) - it('uses Windows CreationDate mismatch as gone while null fields stay unknown', async () => { + it('uses Windows CreationDate as the primary identity regardless of command line', async () => { const base = { platform: 'win32' as const, signalProcess: () => 'occupied' as const @@ -162,8 +171,8 @@ describe('daemon process identity evidence', () => { }) }) ).resolves.toMatchObject({ - state: 'unknown', - reason: 'windows_command_line_unavailable' + state: 'present', + reason: 'windows_identity_match' }) await expect( probeDaemonProcessIdentity(exactIncarnation, endpoint, { @@ -175,8 +184,8 @@ describe('daemon process identity evidence', () => { }) }) ).resolves.toMatchObject({ - state: 'unknown', - reason: 'command_line_mismatch' + state: 'present', + reason: 'windows_identity_match' }) await expect( probeDaemonProcessIdentity(exactIncarnation, endpoint, { @@ -214,8 +223,8 @@ describe('daemon process identity evidence', () => { }) }) ).resolves.toMatchObject({ - state: 'unknown', - reason: 'windows_command_line_unavailable' + state: 'present', + reason: 'windows_identity_match' }) }) @@ -261,7 +270,9 @@ describe('daemon audit availability evidence', () => { }) it('represents failed legacy inventory as unknown, never an empty process list', async () => { - const observation = await classifyDaemonAuditFailure(context, 'inventory_failed', null) + const observation = await classifyDaemonAuditFailure(context, 'inventory_failed', null, { + dependencies: auditClassifierDependencies + }) expect(observation).toMatchObject({ state: 'unknown', @@ -278,7 +289,10 @@ describe('daemon audit availability evidence', () => { context, 'token_missing_after_authenticated_disconnect', null, - ['token_file'] + { + additionalEvidenceSources: ['token_file'], + dependencies: auditClassifierDependencies + } ) expect(observation.state).toBe('unknown') @@ -295,8 +309,11 @@ describe('daemon audit availability evidence', () => { windowsContext, 'inventory_failed', exactIncarnation, - ['windows_named_pipe'], - 'windows_named_pipe_missing' + { + additionalEvidenceSources: ['windows_named_pipe'], + endpointGoneProof: 'windows_named_pipe_missing', + dependencies: auditClassifierDependencies + } ) expect(observation).toMatchObject({ @@ -306,4 +323,23 @@ describe('daemon audit availability evidence', () => { exactIncarnation }) }) + + it('rejects named-pipe disappearance as a gone proof for Unix endpoints', async () => { + const observation = await classifyDaemonAuditFailure( + context, + 'inventory_failed', + exactIncarnation, + { + additionalEvidenceSources: ['windows_named_pipe'], + endpointGoneProof: 'windows_named_pipe_missing', + dependencies: auditClassifierDependencies + } + ) + + expect(observation).toMatchObject({ + state: 'unknown', + reason: 'inventory_failed', + processLiveness: 'unknown' + }) + }) }) diff --git a/src/main/daemon/daemon-incarnation-evidence.ts b/src/main/daemon/daemon-incarnation-evidence.ts index 1367e194b3d..314254cd539 100644 --- a/src/main/daemon/daemon-incarnation-evidence.ts +++ b/src/main/daemon/daemon-incarnation-evidence.ts @@ -222,13 +222,13 @@ async function probeWindowsProcess( exactIncarnation ) } - if (!identity.commandLine) { - return unknown('windows_command_line_unavailable', ['windows_cim']) - } - if (!commandLineMatchesDaemon(identity.commandLine, endpoint.socketPath, endpoint.tokenPath)) { - return unknown('command_line_mismatch', ['windows_cim']) - } - return present('windows_identity_match', ['windows_cim', 'endpoint_identity']) + return present( + 'windows_identity_match', + identity.commandLine && + commandLineMatchesDaemon(identity.commandLine, endpoint.socketPath, endpoint.tokenPath) + ? ['windows_cim', 'process_command_line', 'endpoint_identity'] + : ['windows_cim', 'endpoint_identity'] + ) } export function parseLinuxProcessState(statLine: string): string | null { diff --git a/src/main/daemon/daemon-process-inspection.test.ts b/src/main/daemon/daemon-process-inspection.test.ts new file mode 100644 index 00000000000..2b612fd068c --- /dev/null +++ b/src/main/daemon/daemon-process-inspection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { queryWindowsProcess, readProcessCommandLine } from './daemon-process-inspection' + +describe('daemon process inspection', () => { + it('falls back to ps when Linux procfs returns an empty command line', async () => { + const readTextFile = vi.fn(async () => '') + const runCommand = vi.fn(async () => 'node daemon-entry --socket daemon.sock') + + await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe( + 'node daemon-entry --socket daemon.sock' + ) + expect(readTextFile).toHaveBeenCalledWith('/proc/42/cmdline') + expect(runCommand).toHaveBeenCalledWith('ps', ['-p', '42', '-o', 'command='], 2_000) + }) + + it('uses a non-empty Linux procfs command line without spawning ps', async () => { + const readTextFile = vi.fn(async () => 'node\0daemon-entry') + const runCommand = vi.fn() + + await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe( + 'node\0daemon-entry' + ) + expect(runCommand).not.toHaveBeenCalled() + }) + + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])( + 'rejects unsafe Windows pid %s before command interpolation', + async (pid) => { + const runCommand = vi.fn() + + await expect(queryWindowsProcess(pid, { runCommand })).resolves.toEqual({ + status: 'unavailable' + }) + expect(runCommand).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts index 6a4d51e7269..6dbc8ed55c4 100644 --- a/src/main/daemon/daemon-process-inspection.ts +++ b/src/main/daemon/daemon-process-inspection.ts @@ -9,6 +9,11 @@ import type { const execFileAsync = promisify(execFile) +export type DaemonProcessInspectionDependencies = { + readTextFile?: (path: string) => Promise + runCommand?: (file: string, args: string[], timeoutMs: number) => Promise +} + export function inspectProcessSignal(pid: number): ProcessSignalEvidence { try { process.kill(pid, 0) @@ -34,29 +39,40 @@ export async function readLinuxStat(pid: number): Promise { export async function readProcessCommandLine( pid: number, - platform: NodeJS.Platform + platform: NodeJS.Platform, + dependencies: DaemonProcessInspectionDependencies = {} ): Promise { + const readTextFile = + dependencies.readTextFile ?? (async (path: string) => await readFile(path, 'utf8')) + const runCommand = dependencies.runCommand ?? runInspectionCommand if (platform === 'linux') { try { - return await readFile(`/proc/${pid}/cmdline`, 'utf8') + const procCommandLine = await readTextFile(`/proc/${pid}/cmdline`) + if (procCommandLine.length > 0) { + return procCommandLine + } } catch { // Fall through to ps for procfs privilege or mount restrictions. } } try { - const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'command='], { - encoding: 'utf8', - timeout: 2_000 - }) + const stdout = await runCommand('ps', ['-p', String(pid), '-o', 'command='], 2_000) return stdout.trim() || undefined } catch { return undefined } } -export async function queryWindowsProcess(pid: number): Promise { +export async function queryWindowsProcess( + pid: number, + dependencies: DaemonProcessInspectionDependencies = {} +): Promise { + if (!Number.isSafeInteger(pid) || pid <= 0) { + return { status: 'unavailable' } + } + const runCommand = dependencies.runCommand ?? runInspectionCommand try { - const { stdout } = await execFileAsync( + const stdout = await runCommand( 'powershell.exe', [ '-NoProfile', @@ -68,7 +84,7 @@ export async function queryWindowsProcess(pid: number): Promise { + const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', timeout: timeoutMs }) + return stdout +} + function hasErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 9a8525c4dbf..69ed1bdbf75 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -1484,6 +1484,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { previous: NonNullable current: NonNullable }[] = [] + adapter.onDaemonIdentityChanged(() => { + throw new Error('audit listener failed') + }) adapter.onDaemonIdentityChanged((event) => identityChanges.push(event)) await server.shutdown() @@ -1505,7 +1508,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) await server.start() - await adapter.listProcesses() + await expect(adapter.listProcesses()).resolves.toEqual([]) expect(identityChanges).toEqual([ { @@ -1520,6 +1523,28 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(identityChanges[0]?.current) }) + it('isolates audit observation listeners from inventory and later listeners', async () => { + const laterListener = vi.fn() + adapter.onAuditEligibilityObservation(() => { + throw new Error('audit listener failed') + }) + adapter.onAuditEligibilityObservation(laterListener) + + await expect(adapter.listProcesses()).resolves.toEqual([]) + + expect(laterListener).toHaveBeenCalledOnce() + expect(laterListener).toHaveBeenCalledWith( + expect.objectContaining({ + state: 'present', + reason: 'authenticated_inventory' + }) + ) + expect(adapter.getLastAuditObservation()).toMatchObject({ + state: 'present', + reason: 'authenticated_inventory' + }) + }) + it('audits token ENOENT only after an authenticated disconnect', async () => { const observations: { trigger: string diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 00de7d64d11..c5cc304bd10 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1651,10 +1651,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return } const event = { previous: { ...previous }, current: { ...current } } - // oxlint-disable-next-line unicorn/no-useless-spread -- listeners may unsubscribe during dispatch - for (const listener of [...this.identityChangeListeners]) { - listener(event) - } + notifyAuditListeners(this.identityChangeListeners, event) this.observeAuditFailure('endpoint_identity_changed', previousExactIncarnation, [ 'endpoint_identity' ]) @@ -1682,13 +1679,10 @@ export class DaemonPtyAdapter implements IPtyProvider { additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], endpointGoneProof?: 'windows_named_pipe_missing' ): void { - void classifyDaemonAuditFailure( - this.auditContext, - trigger, - exactIncarnation, + void classifyDaemonAuditFailure(this.auditContext, trigger, exactIncarnation, { additionalEvidenceSources, endpointGoneProof - ) + }) .then((observation) => this.publishAuditObservation(observation)) .catch(() => {}) } @@ -1696,10 +1690,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private publishAuditObservation(observation: DaemonAuditObservation): void { this.lastAuditObservation = observation trackDaemonAuditEligibility(observation) - // oxlint-disable-next-line unicorn/no-useless-spread -- listeners may unsubscribe during dispatch - for (const listener of [...this.auditObservationListeners]) { - listener(observation) - } + notifyAuditListeners(this.auditObservationListeners, observation) } private resyncBackgroundedSessions(): void { @@ -2319,6 +2310,16 @@ function removeListener(listeners: T[], listener: T): void { } } +function notifyAuditListeners(listeners: readonly ((value: T) => void)[], value: T): void { + for (const listener of listeners.slice()) { + try { + listener(value) + } catch { + // Audit observers cannot affect daemon operations. + } + } +} + // Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); // message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. function isDaemonGoneError(err: unknown): boolean { diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 55f33f9d085..eac1820a047 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -633,6 +633,7 @@ describe('killAllProcessesForWorktree', () => { // DaemonClient method so the shared connect+RPC budget path is exercised. ensureConnectedWithin: async () => {}, isConnected: () => true, + getDaemonIdentity: () => null, disconnect: () => {}, notify: () => {}, request: ( diff --git a/src/shared/daemon-audit-eligibility.ts b/src/shared/daemon-audit-eligibility.ts new file mode 100644 index 00000000000..047f95e0665 --- /dev/null +++ b/src/shared/daemon-audit-eligibility.ts @@ -0,0 +1,81 @@ +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', + 'transport_closed' +] as const + +export const DAEMON_EVIDENCE_SOURCE_VALUES = [ + 'authenticated_inventory', + 'boot_identity', + 'endpoint_identity', + 'endpoint_stat', + 'linux_proc_stat', + 'pid_record', + 'process_command_line', + 'process_signal', + 'process_start_time', + 'token_file', + 'windows_cim', + 'windows_named_pipe' +] as const + +export const DAEMON_PROCESS_PRESENT_REASON_VALUES = [ + 'linux_identity_match', + 'macos_identity_match', + 'windows_identity_match' +] as const + +export const DAEMON_PROCESS_GONE_REASON_VALUES = [ + 'linux_boot_changed', + 'linux_start_ticks_mismatch', + 'linux_zombie', + 'pid_missing', + 'windows_creation_time_mismatch', + 'windows_process_missing' +] as const + +export const DAEMON_PROCESS_UNKNOWN_REASON_VALUES = [ + 'command_line_mismatch', + 'command_line_unavailable', + 'exact_identity_unavailable', + 'inspection_failed', + 'linux_identity_incomplete', + 'macos_start_time_mismatch', + 'permission_denied', + 'process_start_time_unavailable', + 'windows_process_start_time_unavailable' +] as const + +export const DAEMON_AUDIT_GONE_REASON_VALUES = [ + ...DAEMON_PROCESS_GONE_REASON_VALUES, + 'windows_named_pipe_missing' +] as const + +export const DAEMON_AUDIT_REASON_VALUES = [ + 'authenticated_inventory', + 'endpoint_identity_changed', + 'inventory_failed', + 'token_missing_after_authenticated_disconnect', + 'transport_closed', + ...DAEMON_AUDIT_GONE_REASON_VALUES +] as const + +export const DAEMON_AUDIT_PROCESS_REASON_VALUES = [ + ...DAEMON_PROCESS_PRESENT_REASON_VALUES, + ...DAEMON_PROCESS_GONE_REASON_VALUES, + ...DAEMON_PROCESS_UNKNOWN_REASON_VALUES, + 'windows_named_pipe_missing' +] as const + +export type DaemonAuditState = (typeof DAEMON_AUDIT_STATE_VALUES)[number] +export type DaemonAuditTrigger = (typeof DAEMON_AUDIT_TRIGGER_VALUES)[number] +export type DaemonAuditFailureTrigger = Exclude +export type DaemonEvidenceSource = (typeof DAEMON_EVIDENCE_SOURCE_VALUES)[number] +export type DaemonProcessPresentReason = (typeof DAEMON_PROCESS_PRESENT_REASON_VALUES)[number] +export type DaemonProcessGoneReason = (typeof DAEMON_PROCESS_GONE_REASON_VALUES)[number] +export type DaemonProcessUnknownReason = (typeof DAEMON_PROCESS_UNKNOWN_REASON_VALUES)[number] +export type DaemonAuditGoneReason = (typeof DAEMON_AUDIT_GONE_REASON_VALUES)[number] diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index c4ab9dd9fbe..bcf92ef54bc 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -27,6 +27,13 @@ import { DAEMON_REPLACE_REASONS, DAEMON_RETIRE_REASONS } from './daemon-lifecycle-telemetry' +import { + DAEMON_AUDIT_PROCESS_REASON_VALUES, + DAEMON_AUDIT_REASON_VALUES, + DAEMON_AUDIT_STATE_VALUES, + DAEMON_AUDIT_TRIGGER_VALUES, + DAEMON_EVIDENCE_SOURCE_VALUES +} from './daemon-audit-eligibility' import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers' import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source' import { appStarSourceSchema } from './gh-star-source' @@ -420,47 +427,10 @@ const daemonLifecycleSchema = z.discriminatedUnion('transition', [ const daemonAuditEligibilitySchema = z .object({ - state: z.enum(['present', 'gone', 'unknown']), - reason: z.enum([ - 'authenticated_inventory', - 'endpoint_identity_changed', - 'inventory_failed', - 'linux_boot_changed', - 'linux_start_ticks_mismatch', - 'linux_zombie', - 'pid_missing', - 'token_missing_after_authenticated_disconnect', - 'transport_closed', - 'windows_creation_time_mismatch', - 'windows_named_pipe_missing', - 'windows_process_missing' - ]), - trigger: z.enum([ - 'endpoint_identity_changed', - 'inventory_answered', - 'inventory_failed', - 'token_missing_after_authenticated_disconnect', - 'transport_closed' - ]), - evidence_sources: z - .array( - z.enum([ - 'authenticated_inventory', - 'boot_identity', - 'endpoint_identity', - 'endpoint_stat', - 'linux_proc_stat', - 'pid_record', - 'process_command_line', - 'process_signal', - 'process_start_time', - 'token_file', - 'windows_cim', - 'windows_named_pipe' - ]) - ) - .min(1) - .max(12), + state: z.enum(DAEMON_AUDIT_STATE_VALUES), + reason: z.enum(DAEMON_AUDIT_REASON_VALUES), + trigger: z.enum(DAEMON_AUDIT_TRIGGER_VALUES), + evidence_sources: z.array(z.enum(DAEMON_EVIDENCE_SOURCE_VALUES)).min(1).max(12), protocol_generation: z.number().int().positive().max(1_000), provider: z.literal('local-daemon'), endpoint_kind: z.enum(['unix-socket', 'windows-named-pipe']), @@ -473,30 +443,7 @@ const daemonAuditEligibilitySchema = z reachability: z.enum(['authenticated', 'disconnected', 'unknown']), inventory_authority: z.enum(['authoritative', 'unavailable']), process_liveness: z.enum(['present', 'gone', 'unknown']), - process_reason: z - .enum([ - 'command_line_mismatch', - 'command_line_unavailable', - 'exact_identity_unavailable', - 'inspection_failed', - 'linux_boot_changed', - 'linux_identity_incomplete', - 'linux_identity_match', - 'linux_start_ticks_mismatch', - 'linux_zombie', - 'macos_identity_match', - 'macos_start_time_mismatch', - 'permission_denied', - 'pid_missing', - 'process_start_time_unavailable', - 'windows_command_line_unavailable', - 'windows_creation_time_mismatch', - 'windows_identity_match', - 'windows_named_pipe_missing', - 'windows_process_missing', - 'windows_process_start_time_unavailable' - ]) - .nullable(), + process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(), endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown']) }) .strict()