chore(daemon): disambiguate audit observations (#12343)
* chore(daemon): disambiguate audit observations * fix(daemon): reject future audit protocol roles * fix(telemetry): protect daemon audit observations
This commit is contained in:
parent
d7fe9d6bcc
commit
13f033f091
|
|
@ -4,7 +4,9 @@ import { join } from 'node:path'
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { validate } from '../telemetry/validator'
|
||||
import { recordAuthenticatedInventory, type DaemonAuditContext } from './daemon-audit-classifier'
|
||||
import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
|
||||
|
|
@ -25,6 +27,16 @@ const context: DaemonAuditContext = {
|
|||
profileScope: '/profile'
|
||||
}
|
||||
|
||||
const exactIncarnation: ExactDaemonIncarnation = {
|
||||
identity: {
|
||||
pid: 92_847_561,
|
||||
startedAtMs: 1_786_000_123_456,
|
||||
launchNonce: 'private-launch-nonce'
|
||||
},
|
||||
linuxStartTicks: '8642097531',
|
||||
bootId: 'private-boot-id'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
trackMock.mockReset()
|
||||
})
|
||||
|
|
@ -42,12 +54,70 @@ describe('daemon audit eligibility telemetry', () => {
|
|||
reason: 'authenticated_inventory',
|
||||
evidence_sources: ['authenticated_inventory'],
|
||||
protocol_generation: 23,
|
||||
generation_role: 'legacy',
|
||||
exact_incarnation: 'unavailable',
|
||||
process_reason: null
|
||||
})
|
||||
expect(props).not.toHaveProperty('exact_incarnation_correlation')
|
||||
expect(validate('daemon_audit_eligibility', props).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('correlates an exact incarnation across app trackers without raw identity fields', () => {
|
||||
const currentContext = { ...context, protocolGeneration: PROTOCOL_VERSION }
|
||||
const firstAppTracker = createDaemonAuditEligibilityTracker()
|
||||
const secondAppTracker = createDaemonAuditEligibilityTracker()
|
||||
|
||||
firstAppTracker(recordAuthenticatedInventory(currentContext, exactIncarnation))
|
||||
secondAppTracker(recordAuthenticatedInventory(currentContext, exactIncarnation))
|
||||
secondAppTracker(
|
||||
recordAuthenticatedInventory(currentContext, {
|
||||
...exactIncarnation,
|
||||
identity: { ...exactIncarnation.identity, launchNonce: 'another-private-launch-nonce' }
|
||||
})
|
||||
)
|
||||
|
||||
const firstProperties = trackMock.mock.calls[0][1]
|
||||
const secondProperties = trackMock.mock.calls[1][1]
|
||||
const replacementProperties = trackMock.mock.calls[2][1]
|
||||
expect(firstProperties).toMatchObject({
|
||||
generation_role: 'current',
|
||||
exact_incarnation: 'endpoint-identity-linux-ticks',
|
||||
exact_incarnation_correlation: 'v1:2751326d0f457808fe11a03ce2f6e732'
|
||||
})
|
||||
expect(secondProperties.exact_incarnation_correlation).toBe(
|
||||
firstProperties.exact_incarnation_correlation
|
||||
)
|
||||
expect(replacementProperties.exact_incarnation_correlation).not.toBe(
|
||||
firstProperties.exact_incarnation_correlation
|
||||
)
|
||||
expect(validate('daemon_audit_eligibility', firstProperties).ok).toBe(true)
|
||||
|
||||
const serializedProperties = JSON.stringify(firstProperties)
|
||||
for (const rawIdentity of [
|
||||
exactIncarnation.identity.launchNonce,
|
||||
String(exactIncarnation.identity.pid),
|
||||
String(exactIncarnation.identity.startedAtMs),
|
||||
exactIncarnation.linuxStartTicks,
|
||||
exactIncarnation.bootId
|
||||
]) {
|
||||
expect(serializedProperties).not.toContain(rawIdentity)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not mislabel a future protocol generation as legacy', () => {
|
||||
const trackEligibility = createDaemonAuditEligibilityTracker()
|
||||
|
||||
expect(() =>
|
||||
trackEligibility(
|
||||
recordAuthenticatedInventory(
|
||||
{ ...context, protocolGeneration: PROTOCOL_VERSION + 1 },
|
||||
exactIncarnation
|
||||
)
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cannot affect callers when telemetry throws', () => {
|
||||
trackMock.mockImplementation(() => {
|
||||
throw new Error('transport failed')
|
||||
|
|
@ -83,13 +153,13 @@ describe('daemon audit eligibility telemetry', () => {
|
|||
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
nowMs += 1_000
|
||||
trackEligibility(recordAuthenticatedInventory(context, null))
|
||||
trackEligibility(recordAuthenticatedInventory(context, exactIncarnation))
|
||||
}
|
||||
|
||||
expect(trackMock).toHaveBeenCalledOnce()
|
||||
|
||||
nowMs += 5 * 60_000
|
||||
trackEligibility(recordAuthenticatedInventory(context, null))
|
||||
trackEligibility(recordAuthenticatedInventory(context, exactIncarnation))
|
||||
expect(trackMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import { track } from '../telemetry/client'
|
||||
import type { EventProps } from '../../shared/telemetry-events'
|
||||
import type { DaemonAuditObservation } from './daemon-audit-classifier'
|
||||
import { PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
|
||||
// Why: a steady daemon repeats a byte-identical observation on every listProcesses call, so
|
||||
// repeats are re-sent only as an occasional heartbeat — the shared per-session telemetry
|
||||
// ceiling is 1,000 events for the whole app and audit data must not crowd it out.
|
||||
const REPEATED_OBSERVATION_INTERVAL_MS = 5 * 60_000
|
||||
const INCARNATION_CORRELATION_DOMAIN = 'orca:daemon-audit-eligibility:incarnation:v1'
|
||||
type AuditEligibilityCommonProperties = Omit<
|
||||
Extract<EventProps<'daemon_audit_eligibility'>, { exact_incarnation: 'unavailable' }>,
|
||||
'exact_incarnation'
|
||||
>
|
||||
|
||||
export function trackDaemonAuditEligibility(observation: DaemonAuditObservation): void {
|
||||
try {
|
||||
track('daemon_audit_eligibility', auditEligibilityProperties(observation))
|
||||
track('daemon_audit_eligibility', auditEligibilityProperties(observation, hashLaunchNonce))
|
||||
} catch {
|
||||
// Audit telemetry cannot affect daemon availability.
|
||||
}
|
||||
|
|
@ -22,11 +29,13 @@ export function createDaemonAuditEligibilityTracker(
|
|||
): (observation: DaemonAuditObservation) => void {
|
||||
let lastProperties: string | null = null
|
||||
let lastTrackedAtMs = 0
|
||||
const correlateLaunchNonce = createLaunchNonceCorrelationCache()
|
||||
return (observation) => {
|
||||
// Why: the rate-limit bookkeeping runs inside the daemon's inventory path, so it is guarded
|
||||
// together with the emit — audit telemetry cannot affect daemon availability.
|
||||
try {
|
||||
const properties = JSON.stringify(auditEligibilityProperties(observation))
|
||||
const eventProperties = auditEligibilityProperties(observation, correlateLaunchNonce)
|
||||
const properties = JSON.stringify(eventProperties)
|
||||
const observedAtMs = monotonicNowMs()
|
||||
const elapsedMs = observedAtMs - lastTrackedAtMs
|
||||
if (
|
||||
|
|
@ -38,7 +47,7 @@ export function createDaemonAuditEligibilityTracker(
|
|||
}
|
||||
lastProperties = properties
|
||||
lastTrackedAtMs = observedAtMs
|
||||
trackDaemonAuditEligibility(observation)
|
||||
track('daemon_audit_eligibility', eventProperties)
|
||||
} catch {
|
||||
// Audit telemetry cannot affect daemon availability.
|
||||
}
|
||||
|
|
@ -46,33 +55,73 @@ export function createDaemonAuditEligibilityTracker(
|
|||
}
|
||||
|
||||
function auditEligibilityProperties(
|
||||
observation: DaemonAuditObservation
|
||||
observation: DaemonAuditObservation,
|
||||
correlateLaunchNonce: (launchNonce: string) => string
|
||||
): EventProps<'daemon_audit_eligibility'> {
|
||||
return {
|
||||
const generationRole = generationRoleForProtocol(observation.context.protocolGeneration)
|
||||
const commonProperties: AuditEligibilityCommonProperties = {
|
||||
state: observation.state,
|
||||
reason: observation.reason,
|
||||
trigger: observation.trigger,
|
||||
evidence_sources: [...observation.evidenceSources],
|
||||
protocol_generation: observation.context.protocolGeneration,
|
||||
generation_role: generationRole,
|
||||
provider: observation.context.provider,
|
||||
endpoint_kind: observation.context.endpointKind,
|
||||
profile_scope: observation.context.profileScope ? 'configured' : 'unspecified',
|
||||
exact_incarnation: exactIncarnationKind(observation),
|
||||
reachability: observation.reachability,
|
||||
inventory_authority: observation.inventoryAuthority,
|
||||
process_liveness: observation.processLiveness,
|
||||
process_reason: observation.processReason,
|
||||
endpoint_state: observation.endpointState
|
||||
}
|
||||
if (!observation.exactIncarnation) {
|
||||
return { ...commonProperties, exact_incarnation: 'unavailable' }
|
||||
}
|
||||
return {
|
||||
...commonProperties,
|
||||
exact_incarnation: exactIncarnationKind(observation.exactIncarnation),
|
||||
exact_incarnation_correlation: correlateLaunchNonce(
|
||||
observation.exactIncarnation.identity.launchNonce
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function generationRoleForProtocol(protocolGeneration: number): 'current' | 'legacy' {
|
||||
if (protocolGeneration === PROTOCOL_VERSION) {
|
||||
return 'current'
|
||||
}
|
||||
if (protocolGeneration > 0 && protocolGeneration < PROTOCOL_VERSION) {
|
||||
return 'legacy'
|
||||
}
|
||||
throw new Error('unsupported_daemon_audit_protocol_generation')
|
||||
}
|
||||
|
||||
function exactIncarnationKind(
|
||||
observation: DaemonAuditObservation
|
||||
): 'endpoint-identity' | 'endpoint-identity-linux-ticks' | 'unavailable' {
|
||||
if (!observation.exactIncarnation) {
|
||||
return 'unavailable'
|
||||
}
|
||||
return observation.exactIncarnation.linuxStartTicks && observation.exactIncarnation.bootId
|
||||
exactIncarnation: NonNullable<DaemonAuditObservation['exactIncarnation']>
|
||||
): 'endpoint-identity' | 'endpoint-identity-linux-ticks' {
|
||||
return exactIncarnation.linuxStartTicks && exactIncarnation.bootId
|
||||
? 'endpoint-identity-linux-ticks'
|
||||
: 'endpoint-identity'
|
||||
}
|
||||
|
||||
function createLaunchNonceCorrelationCache(): (launchNonce: string) => string {
|
||||
let cachedNonce: string | null = null
|
||||
let cachedCorrelation = ''
|
||||
return (launchNonce) => {
|
||||
if (launchNonce !== cachedNonce) {
|
||||
cachedNonce = launchNonce
|
||||
cachedCorrelation = hashLaunchNonce(launchNonce)
|
||||
}
|
||||
return cachedCorrelation
|
||||
}
|
||||
}
|
||||
|
||||
function hashLaunchNonce(launchNonce: string): string {
|
||||
const digest = createHash('sha256')
|
||||
.update(INCARNATION_CORRELATION_DOMAIN)
|
||||
.update('\0')
|
||||
.update(launchNonce)
|
||||
.digest('hex')
|
||||
return `v1:${digest.slice(0, 32)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ describe('telemetry IPC handlers', () => {
|
|||
count_bucket: 'count_1',
|
||||
bucket_source: 'crossed_now'
|
||||
})
|
||||
handler({}, 'daemon_audit_eligibility', {})
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
expect(getCohortAtEmitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ let storeRef: Store | null = null
|
|||
|
||||
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
|
||||
'app_starred_orca',
|
||||
'daemon_audit_eligibility',
|
||||
'star_nag_outcome',
|
||||
'feature_interaction_usage_bucket_reached'
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export const DAEMON_AUDIT_STATE_VALUES = ['present', 'gone', 'unknown'] as const
|
||||
|
||||
export const DAEMON_AUDIT_GENERATION_ROLE_VALUES = ['current', 'legacy'] as const
|
||||
|
||||
export const DAEMON_AUDIT_TRIGGER_VALUES = [
|
||||
'inventory_answered',
|
||||
'inventory_failed',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
DAEMON_RETIRE_REASONS
|
||||
} from './daemon-lifecycle-telemetry'
|
||||
import {
|
||||
DAEMON_AUDIT_GENERATION_ROLE_VALUES,
|
||||
DAEMON_AUDIT_PROCESS_REASON_VALUES,
|
||||
DAEMON_AUDIT_REASON_VALUES,
|
||||
DAEMON_AUDIT_STATE_VALUES,
|
||||
|
|
@ -419,28 +420,38 @@ const daemonLifecycleSchema = z.discriminatedUnion('transition', [
|
|||
.strict()
|
||||
])
|
||||
|
||||
const daemonAuditEligibilitySchema = z
|
||||
.object({
|
||||
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']),
|
||||
profile_scope: z.enum(['configured', 'unspecified']),
|
||||
exact_incarnation: z.enum([
|
||||
'endpoint-identity',
|
||||
'endpoint-identity-linux-ticks',
|
||||
'unavailable'
|
||||
]),
|
||||
reachability: z.enum(['authenticated', 'disconnected', 'unknown']),
|
||||
inventory_authority: z.enum(['authoritative', 'unavailable']),
|
||||
process_liveness: z.enum(['present', 'gone', 'unknown']),
|
||||
process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(),
|
||||
endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown'])
|
||||
})
|
||||
.strict()
|
||||
const daemonAuditEligibilityBaseSchema = z.object({
|
||||
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),
|
||||
generation_role: z.enum(DAEMON_AUDIT_GENERATION_ROLE_VALUES),
|
||||
provider: z.literal('local-daemon'),
|
||||
endpoint_kind: z.enum(['unix-socket', 'windows-named-pipe']),
|
||||
profile_scope: z.enum(['configured', 'unspecified']),
|
||||
reachability: z.enum(['authenticated', 'disconnected', 'unknown']),
|
||||
inventory_authority: z.enum(['authoritative', 'unavailable']),
|
||||
process_liveness: z.enum(['present', 'gone', 'unknown']),
|
||||
process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(),
|
||||
endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown'])
|
||||
})
|
||||
|
||||
const daemonAuditEligibilitySchema = z.discriminatedUnion('exact_incarnation', [
|
||||
daemonAuditEligibilityBaseSchema
|
||||
.extend({
|
||||
exact_incarnation: z.literal('endpoint-identity'),
|
||||
exact_incarnation_correlation: z.string().regex(/^v1:[0-9a-f]{32}$/)
|
||||
})
|
||||
.strict(),
|
||||
daemonAuditEligibilityBaseSchema
|
||||
.extend({
|
||||
exact_incarnation: z.literal('endpoint-identity-linux-ticks'),
|
||||
exact_incarnation_correlation: z.string().regex(/^v1:[0-9a-f]{32}$/)
|
||||
})
|
||||
.strict(),
|
||||
daemonAuditEligibilityBaseSchema.extend({ exact_incarnation: z.literal('unavailable') }).strict()
|
||||
])
|
||||
|
||||
// Rollout signal for granting Codex hook trust via codex app-server RPCs
|
||||
// instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed`
|
||||
|
|
|
|||
Loading…
Reference in New Issue