From 325ed613be29fa80ef382dab5572812429b3e6fb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Sun, 27 Sep 2026 19:23:09 -0400 Subject: [PATCH] Verify runtime ownership before stop --- docs/PORTABLE_AGENT_FLEET_SPEC.md | 8 ++++++ ...grity-note-2026-09-27-portable-kernel.yaml | 4 ++- src/adapters/claude.mjs | 27 ++++++++++++++++-- src/cli.mjs | 4 ++- test/fleet/mcp-adapter.test.mjs | 28 +++++++++++++++++++ 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index f500090..45200c3 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -933,6 +933,14 @@ domain implementation. A domain session works only in its assigned worktree. TORCH MUST inspect process ownership and working directory before stopping a process. Parentage alone does not establish ownership. +Implementation checkpoint (2026-09-27): the Claude stop adapter resolves the +durable runtime-session ID through the provider's live session inventory and +requires its reported working directory to equal the managed identity's +worktree before issuing a stop. Missing ownership evidence or a mismatched +directory fails closed. A provider-reported offline session is treated as +already stopped. Codex declares stop unsupported and is never silently killed +through an operating-system process heuristic. + ### 16.2 Safe convergence TORCH MUST distinguish scheduled observation from scheduled mutation: diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index dec9ebc..1ae3caa 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -93,7 +93,7 @@ test_integrity_note: - id: SCN-claude-adapter given: a Claude runtime adapter with a virtualized command runner when: sessions are planned and runtime state is queried - then: command construction is deterministic, capabilities are explicit, and planning launches no process + then: command construction is deterministic, capabilities are explicit, planning launches no process, and stop requires matching live session and worktree ownership evidence - id: SCN-codex-adapter given: a Codex runtime adapter with a virtualized command runner and an identity-bound TORCH control plane when: create, resume, MCP configuration, runtime-ID capture, and steering are planned or attempted @@ -257,6 +257,7 @@ test_integrity_note: - SQLite state after process restart - CLI and MCP output over real process boundaries - runtime command plans and execution order + - runtime stop session identity, reported working directory, and fail-closed ownership mismatch - local resume snapshot, tracked resume brief, final worker message, preserved task identity, and presence transitions - immutable check receipt commit and result - resource holder, FIFO position, expiry, and cancellation state @@ -299,6 +300,7 @@ test_integrity_note: - repository content redirecting machine-local database writes - MCP and CLI drifting onto separate business logic - launch plans that execute provider commands during preview + - stopping a runtime by PID, parentage, or session label without matching managed-worktree evidence - a mixed-runtime claim implemented as renamed Claude-only commands - runtime sessions launching without their identity-bound TORCH tool surface - lost Codex thread IDs or native steering that bypasses durable messaging diff --git a/src/adapters/claude.mjs b/src/adapters/claude.mjs index b2d2127..6aaad07 100644 --- a/src/adapters/claude.mjs +++ b/src/adapters/claude.mjs @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { accessSync, constants, existsSync } from 'node:fs'; -import { delimiter, join } from 'node:path'; +import { delimiter, join, resolve } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; import { unsupportedCapability, validateRuntimeAdapter } from './runtime.mjs'; @@ -161,10 +161,31 @@ export class ClaudeRuntimeAdapter { return session ?? { sessionId: id, status: 'offline' }; } - stopSession({ runtimeSessionId } = {}) { + stopSession({ runtimeSessionId, worktree } = {}) { const id = text(runtimeSessionId, 'runtimeSessionId'); + const expectedWorktree = resolve(text(worktree, 'worktree')); + const session = this.getStatus({ runtimeSessionId: id }); + if (session.status === 'offline') { + return { runtimeSessionId: id, stopped: true, alreadyOffline: true }; + } + const reportedWorktree = session.cwd ?? session.worktree + ?? session.workingDirectory ?? session.working_directory; + if (typeof reportedWorktree !== 'string' || !reportedWorktree.trim()) { + throw new TorchError('Claude session stop refused because its working directory is unavailable', { + code: 'RUNTIME_OWNERSHIP_UNVERIFIED', details: { runtimeSessionId: id, expectedWorktree }, + }); + } + if (resolve(reportedWorktree) !== expectedWorktree) { + throw new TorchError('Claude session stop refused because its working directory does not match the managed identity', { + code: 'RUNTIME_OWNERSHIP_MISMATCH', + details: { runtimeSessionId: id, expectedWorktree, reportedWorktree: resolve(reportedWorktree) }, + }); + } const result = this.run(['stop', id]); - return { runtimeSessionId: id, stopped: result.status === undefined || result.status === 0 }; + return { + runtimeSessionId: id, stopped: result.status === undefined || result.status === 0, + verifiedWorktree: expectedWorktree, alreadyOffline: false, + }; } captureRuntimeId({ areaId, runtimeSessionId } = {}) { diff --git a/src/cli.mjs b/src/cli.mjs index eef7245..71ebad8 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -221,7 +221,9 @@ function stopThrough(adapters, action) { if (!adapter) throw new TorchError(`No runtime stopper for ${action.runtime}`, { code: 'RUNTIME_ADAPTER_MISSING', details: { runtime: action.runtime }, }); - return adapter.stopSession({ runtimeSessionId: action.runtimeSessionId }); + return adapter.stopSession({ + runtimeSessionId: action.runtimeSessionId, worktree: action.worktree?.path, + }); } function runCandidateAcceptance(candidateRoot, spawn) { diff --git a/test/fleet/mcp-adapter.test.mjs b/test/fleet/mcp-adapter.test.mjs index e760db1..c4b45e1 100644 --- a/test/fleet/mcp-adapter.test.mjs +++ b/test/fleet/mcp-adapter.test.mjs @@ -100,6 +100,34 @@ test('SCN-claude-adapter: planning is deterministic, capabilities are honest, an const noRunner = createClaudeAdapter({ idFactory: () => 'never-run' }); assert.throws(() => noRunner.listSessions(), (error) => error.code === 'RUNTIME_EXECUTION_NOT_AUTHORIZED'); + + const stoppedCalls = []; + const stoppable = createClaudeAdapter({ + runner: (command, args) => { + stoppedCalls.push({ command, args }); + if (args[0] === 'agents') { + return { status: 0, stdout: '[{"sessionId":"owned-session","status":"idle","cwd":"/tmp/owned-worktree"}]' }; + } + return { status: 0, stdout: '' }; + }, + }); + const stopped = stoppable.stopSession({ + runtimeSessionId: 'owned-session', worktree: '/tmp/owned-worktree', + }); + assert.equal(stopped.stopped, true); + assert.equal(stopped.verifiedWorktree, '/tmp/owned-worktree'); + assert.deepEqual(stoppedCalls.map((call) => call.args[0]), ['agents', 'stop']); + assert.throws( + () => stoppable.stopSession({ runtimeSessionId: 'owned-session', worktree: '/tmp/not-owned' }), + (error) => error.code === 'RUNTIME_OWNERSHIP_MISMATCH', + ); + const unverifiable = createClaudeAdapter({ + runner: () => ({ status: 0, stdout: '[{"sessionId":"opaque-session","status":"idle"}]' }), + }); + assert.throws( + () => unverifiable.stopSession({ runtimeSessionId: 'opaque-session', worktree: '/tmp/owned-worktree' }), + (error) => error.code === 'RUNTIME_OWNERSHIP_UNVERIFIED', + ); }); test('SCN-codex-adapter: turns are resumable, identity-bound, durable, and capability-honest', () => {