Verify runtime ownership before stop

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 19:23:09 -04:00
1 parent 6e79960721
commit 325ed613be
5 files changed
+66 -5

No files matched your search

+8
View File
@@ -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:
@@ -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
+24 -3
View File
@@ -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 } = {}) {
+3 -1
View File
@@ -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) {
+28
View File
@@ -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', () => {