Prove Claude resume owner identity

This commit is contained in:
Brennan Benson
2026-08-27 07:28:02 -07:00
parent 09599d61e2
commit e1caa73456
9 changed files with 241 additions and 82 deletions
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
isClaudeResumeProcessCommandLine,
readClaudeResumeProcessIdentity
} from './claude-resume-process-proof'
const SESSION_ID = 'session-claude-1'
describe('Claude resume process proof', () => {
it('refuses a lingering Claude child for another session', async () => {
await expect(
readClaudeResumeProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-new',
sessionId: SESSION_ID,
platform: 'darwin',
readPosixRows: async () => [
{ pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' },
{ pid: 101, ppid: 100, stat: 'S+', command: 'claude --resume session-other' }
],
timeoutMs: 0
})
).rejects.toThrow('one exact Claude child process')
})
it('excludes the previous owner and all of its descendants', async () => {
await expect(
readClaudeResumeProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-new',
sessionId: SESSION_ID,
platform: 'darwin',
readPosixRows: async () => [
{ pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' },
{ pid: 101, ppid: 100, stat: 'S+', command: `claude --resume ${SESSION_ID}` },
{ pid: 102, ppid: 101, stat: 'S+', command: `claude --resume ${SESSION_ID}` }
],
excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }],
timeoutMs: 0
})
).rejects.toThrow('one exact Claude child process')
})
it('requires a PID-reuse-safe start time for the new owner', async () => {
await expect(
readClaudeResumeProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-new',
sessionId: SESSION_ID,
platform: 'darwin',
readPosixRows: async () => [
{ pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' },
{ pid: 101, ppid: 100, stat: 'S+', command: `claude --resume ${SESSION_ID}` }
],
readStartTime: async () => null,
timeoutMs: 0
})
).rejects.toThrow('PID-reuse-safe start time')
})
it('matches adjacent resume and session-id argv tokens exactly', () => {
expect(
isClaudeResumeProcessCommandLine(`claude --resume ${SESSION_ID}`, SESSION_ID, 'darwin')
).toBe(true)
expect(
isClaudeResumeProcessCommandLine(`claude --session-id ${SESSION_ID}`, SESSION_ID, 'darwin')
).toBe(true)
expect(
isClaudeResumeProcessCommandLine(`claude --resume ${SESSION_ID}-other`, SESSION_ID, 'darwin')
).toBe(false)
expect(
isClaudeResumeProcessCommandLine(`claude --resume=${SESSION_ID}`, SESSION_ID, 'darwin')
).toBe(false)
})
})
@@ -0,0 +1,39 @@
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { tokenizeProcessCommandLine } from '../runtime/process-command-line-tokens'
import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity'
export function isClaudeResumeProcessCommandLine(
commandLine: string,
sessionId: string,
platform: NodeJS.Platform = process.platform
): boolean {
const tokens = tokenizeProcessCommandLine(commandLine, platform)
if (!tokens || !sessionId) {
return false
}
return tokens.some(
(token, index) =>
(token === '--resume' || token === '--session-id') && tokens[index + 1] === sessionId
)
}
type StructuredTuiIdentityInput = Parameters<typeof readStructuredTuiProcessIdentity>[0]
export async function readClaudeResumeProcessIdentity(
input: Omit<StructuredTuiIdentityInput, 'agent' | 'processCommandMatches'> & {
sessionId: string
}
): Promise<AgentSessionProcessIdentity> {
const { sessionId, ...identityInput } = input
const platform = input.platform ?? process.platform
const identity = await readStructuredTuiProcessIdentity({
...identityInput,
agent: 'claude',
processCommandMatches: (command) =>
isClaudeResumeProcessCommandLine(command, sessionId, platform)
})
if (identity.processStartTimeMs === null) {
throw new Error('The resumed Claude child process has no PID-reuse-safe start time.')
}
return identity
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { claudeProcessIdentity } from './claude-structured-owner-identity'
import { claudeProcessIdentity, claudeProviderHandleLink } from './claude-structured-owner-identity'
const IDENTITY = {
sessionId: 'session-identity',
@@ -10,6 +10,25 @@ const IDENTITY = {
}
describe('claude process identity', () => {
it('keeps the provider link id stable when the advisory leaf advances', () => {
const first = claudeProviderHandleLink({
sessionId: 'provider-1',
leafUuid: 'leaf-a',
resumed: false,
fence: 7,
observedAt: 1
})
const second = claudeProviderHandleLink({
sessionId: 'provider-1',
leafUuid: 'leaf-b',
resumed: true,
fence: 7,
observedAt: 2
})
expect(second.linkId).toBe(first.linkId)
expect(second.handle).toMatchObject({ provider: 'claude', leafUuid: 'leaf-b' })
})
it('records the observed start time alongside the spawn token', async () => {
await expect(
claudeProcessIdentity(
@@ -48,9 +48,7 @@ export function claudeProviderHandleLink(input: {
observedAt: number
}): AgentSessionProviderHandleLink {
return {
linkId:
input.linkId ??
`claude-${input.fence}-${input.sessionId}-${input.leafUuid ?? 'empty'}`.slice(0, 128),
linkId: input.linkId ?? `claude-${input.fence}-${input.sessionId}`.slice(0, 128),
handle: { provider: 'claude', sessionId: input.sessionId, leafUuid: input.leafUuid },
origin: input.origin ?? (input.resumed ? 'resumed' : 'created'),
mintedAtFence: input.fence,
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { proveClaudeTranscriptBranchFromJsonl } from './claude-transcript-branch-proof'
describe('Claude transcript branch proof', () => {
it('accepts a new leaf on a sibling branch as diagnostic evidence', () => {
const contents = [
JSON.stringify({ uuid: 'root', parentUuid: null, sessionId: 'session-1' }),
JSON.stringify({ uuid: 'old-leaf', parentUuid: 'root', sessionId: 'session-1' }),
JSON.stringify({ uuid: 'new-leaf', parentUuid: 'root', sessionId: 'session-1' }),
JSON.stringify({ type: 'last-prompt', sessionId: 'session-1', leafUuid: 'new-leaf' })
].join('\n')
expect(
proveClaudeTranscriptBranchFromJsonl({
contents,
providerSessionId: 'session-1',
previousLeafUuid: 'old-leaf'
})
).toEqual({ leafUuid: 'new-leaf', relation: 'sibling' })
})
})
@@ -9,7 +9,7 @@ type TranscriptNode = {
export type ClaudeTranscriptBranchProof = {
leafUuid: string
relation: 'initial' | 'same' | 'descendant'
relation: 'initial' | 'same' | 'descendant' | 'sibling'
}
function nonEmptyString(value: unknown): string | null {
@@ -120,7 +120,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
}
const previous = nodes.get(previousLeafUuid)
if (!previous || previous.sessionId !== input.providerSessionId) {
throw transcriptError('previous cursor is missing from the session graph')
return { leafUuid, relation: 'sibling' }
}
if (leafUuid === previousLeafUuid) {
return { leafUuid, relation: 'same' }
@@ -144,7 +144,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
if (cursor !== null) {
throw transcriptError('ancestry exceeds the bounded proof limit')
}
throw transcriptError('latest marker is on a sibling branch')
return { leafUuid, relation: 'sibling' }
}
export async function proveClaudeTranscriptBranch(input: {
+1 -73
View File
@@ -1,79 +1,7 @@
import { tokenizeCustomCommandTemplate } from '../../shared/commit-message-prompt'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { tokenizeProcessCommandLine } from '../runtime/process-command-line-tokens'
import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity'
const PROCESS_COMMAND_LINE_MAX_CHARS = 16 * 1024
function tokenizeWindowsProcessCommandLine(commandLine: string): string[] {
const tokens: string[] = []
let current = ''
let quoted = false
let started = false
let index = 0
while (index < commandLine.length) {
const char = commandLine[index]!
if (!started && /\s/.test(char)) {
index += 1
continue
}
started = true
if (char === '\\') {
let backslashes = 0
while (commandLine[index] === '\\') {
backslashes += 1
index += 1
}
if (commandLine[index] === '"') {
current += '\\'.repeat(Math.floor(backslashes / 2))
if (backslashes % 2 === 1) {
current += '"'
index += 1
}
} else {
current += '\\'.repeat(backslashes)
}
continue
}
if (char === '"') {
if (quoted && commandLine[index + 1] === '"') {
current += '"'
index += 2
continue
}
quoted = !quoted
index += 1
continue
}
if (!quoted && /\s/.test(char)) {
tokens.push(current)
current = ''
started = false
index += 1
continue
}
current += char
index += 1
}
if (started) {
tokens.push(current)
}
return tokens
}
function tokenizeProcessCommandLine(
commandLine: string,
platform: NodeJS.Platform
): string[] | null {
if (!commandLine || commandLine.length > PROCESS_COMMAND_LINE_MAX_CHARS) {
return null
}
if (platform === 'win32') {
return tokenizeWindowsProcessCommandLine(commandLine)
}
const parsed = tokenizeCustomCommandTemplate(commandLine)
return parsed.ok ? parsed.tokens : null
}
export function isCodexResumeProcessCommandLine(
commandLine: string,
threadId: string,
+6 -2
View File
@@ -103,6 +103,7 @@ import {
import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity'
import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity'
import { readCodexResumeProcessIdentity } from '../codex/codex-resume-process-proof'
import { readClaudeResumeProcessIdentity } from '../claude/claude-resume-process-proof'
import {
proveCodexTuiRollout,
resolvePinnedCodexRolloutProof
@@ -11317,11 +11318,14 @@ export class OrcaRuntimeService {
spawnToken,
threadId: head.handle.threadId
})
: await readStructuredTuiProcessIdentity({
: await readClaudeResumeProcessIdentity({
hostId: record.location.executionHostId,
rootPid: terminal.processId,
spawnToken,
agent: provider
sessionId: head.handle.sessionId,
excludedProcessTreeRootIdentities: record.lease.ownerProcess
? [record.lease.ownerProcess]
: undefined
}),
link:
provider === 'codex'
@@ -0,0 +1,73 @@
import { tokenizeCustomCommandTemplate } from '../../shared/commit-message-prompt'
const PROCESS_COMMAND_LINE_MAX_CHARS = 16 * 1024
function tokenizeWindowsProcessCommandLine(commandLine: string): string[] {
const tokens: string[] = []
let current = ''
let quoted = false
let started = false
let index = 0
while (index < commandLine.length) {
const char = commandLine[index]!
if (!started && /\s/.test(char)) {
index += 1
continue
}
started = true
if (char === '\\') {
let backslashes = 0
while (commandLine[index] === '\\') {
backslashes += 1
index += 1
}
if (commandLine[index] === '"') {
current += '\\'.repeat(Math.floor(backslashes / 2))
if (backslashes % 2 === 1) {
current += '"'
index += 1
}
} else {
current += '\\'.repeat(backslashes)
}
continue
}
if (char === '"') {
if (quoted && commandLine[index + 1] === '"') {
current += '"'
index += 2
continue
}
quoted = !quoted
index += 1
continue
}
if (!quoted && /\s/.test(char)) {
tokens.push(current)
current = ''
started = false
index += 1
continue
}
current += char
index += 1
}
if (started) {
tokens.push(current)
}
return tokens
}
export function tokenizeProcessCommandLine(
commandLine: string,
platform: NodeJS.Platform
): string[] | null {
if (!commandLine || commandLine.length > PROCESS_COMMAND_LINE_MAX_CHARS) {
return null
}
if (platform === 'win32') {
return tokenizeWindowsProcessCommandLine(commandLine)
}
const parsed = tokenizeCustomCommandTemplate(commandLine)
return parsed.ok ? parsed.tokens : null
}