<objective> Extend the session execution runner with bounded exec capture and egress plumbing while preserving every Phase 3 behavior.
Purpose: Deliver the D-94 runner half and the C-3 egress validation point that the dispatcher exec path depends on. Output: Extended session-execution module, updated runner tests, and the fake-openshell capture fields. </objective>
<execution_context> @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md </execution_context>
<context> @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-CONTEXT.md @.planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-PATTERNS.md </context>
<tasks>
<task type="tdd"> <name>Task 1: Bounded exec capture in the OpenShell runner (D-94, C-6, D-108)</name> <files>src/runtime/session-execution.ts, test/support/fake-openshell.ts, test/runtime/session-execution.test.ts</files> <read_first> - src/runtime/openshell.ts lines 41-55 (captureOutput flag, widened outcome, OpenShellRunnerError from plan 04-03) - src/runtime/session-execution.ts lines 241-275 (spawnOpenShellRunner: stdio ignore, RUNNER_FAILED and RUNNER_TIMED_OUT rejections) - test/support/fake-openshell.ts lines 17-20 and 65-84 (logged argv, timeoutMs, exitCode returns, and the hang step the TOOL-04 kill test uses) - test/runtime/session-execution.test.ts lines 269-310 (existing runner tests that must keep passing) </read_first> <behavior> - Test (TOOL-03): exec with capture returns stdout and stderr bounded at 262144 bytes with truncated set when the output exceeds the bound - Test (TOOL-03): a runner rejection during exec maps to REALM_NOT_ACTIVE; a deadline expiry maps to TOOL_TIMEOUT and the runner receives its kill so nothing keeps running - Test (TOOL-03): a non-zero remote exit code is a successful call carrying exitCode, not an error - Test titles cite TOOL-03 </behavior> <action>Write the failing tests first per behavior, then extend spawnOpenShellRunner in src/runtime/session-execution.ts per D-94 and C-6: when captureOutput is true, collect stdout/stderr up to 262144 bytes each with truncated set on overflow; when false keep stdio ignore so the create path is byte-identical. Map OpenShellRunnerError kind failed to REALM_NOT_ACTIVE (sandbox not reachable) and kind timed-out to TOOL_TIMEOUT via makeRealmError, with no child message text crossing the boundary; a timed-out exec kills the process tree in its own timer (the dispatcher's withTimeout race is a second bound, not the kill, per D-91). Only exec reads the captured fields; the create path ignores them. Extend test/support/fake-openshell.ts so its exitCode returns gain stdout, stderr, and truncated while keeping the logged argv and timeoutMs (its lines 17-20 and 65-67). Keep every existing runner test passing. (Implements D-94 runner half and C-6; OQ-P4-05 default: remote exit code via CLI status.)</action> <acceptance_criteria> - test/runtime/session-execution.test.ts asserts bounded capture with truncation, both error mappings with the kill, and non-zero exit as success - All pre-existing runner tests still pass unchanged in behavior </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/session-execution.test.js</automated> </verify> <done>Bounded exec capture green with kill-on-timeout and correct mappings.</done> </task>
<task type="tdd"> <name>Task 2: Egress validation and policy plumbing at create (D-81, C-3, D-106)</name> <files>src/runtime/session-execution.ts, test/runtime/session-execution.test.ts</files> <read_first> - src/runtime/session-execution.ts lines 52 and 87-110 (SessionExecutionOptions and the create() policy-write-before-create sequence with the line-91 writePolicyDocument call) - src/runtime/config.ts (from plan 04-03; absolute workloadArgv[0] rule already enforced at load) - src/runtime/egress.ts (from plan 04-01; validateEgressAllow to call) - test/runtime/session-execution.test.ts line 164 (the buildPolicyDocument() call to update) </read_first> <behavior> - Test (RT-05): create() passes the egress list and runtime.workloadArgv[0] into the policy write, and the write lands before the create argv - Test (RT-05): an invalid allowlist throws SCHEMA_INVALID before any create call (no OpenShell invocation on the fake log) - Test titles cite RT-05 </behavior> <action>Write the failing tests first per behavior, then apply the C-3 recommended resolution in src/runtime/session-execution.ts: add egressAllow to SessionExecutionOptions and run validateEgressAllow in the SessionExecution constructor so an invalid list throws before any create (the earliest shared point; loadPolicyFile is not edited per the pattern map). Update create() to pass the egress list and runtime.workloadArgv[0] through writePolicyDocument into buildPolicyDocument(egress, workloadBinary) from plan 04-03, preserving the write-before-create order. Update the line-164 buildPolicyDocument() call in the session-execution test. (Implements C-3 and the create-side half of D-81; planner decision recorded: constructor validation rather than changing loadPolicyFile. OQ-P4-10 default: egress fixed at create; change means destroy and recreate.)</action> <acceptance_criteria> - test/runtime/session-execution.test.ts asserts egress and workload binary reach the policy write in order and that invalid lists fail with zero create calls - The write-before-create ordering assertion still holds </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/session-execution.test.js</automated> </verify> <done>Egress plumbed to the policy write with constructor validation.</done> </task>
<task type="auto"> <name>Task 3: D-80 argv refusal coverage on the execution path</name> <files>test/runtime/session-execution.test.ts</files> <read_first> - test/runtime/session-execution.test.ts lines 26-80 (execution fixture) - test/runtime/openshell-argv.test.ts (from plan 04-03; the refusal-table assertions this test mirrors at the execution layer) </read_first> <action>Extend test/runtime/session-execution.test.ts to assert that no argv the execution path produces (create or exec) contains --env, --provider, --driver-config-json, --forward, --expose, --template, or --auto-providers, mirroring the plan 04-03 refusal table at the layer that actually spawns. Test titles cite RT-05. (Implements the execution-layer half of D-80.)</action> <acceptance_criteria> - The execution test asserts every D-80 forbidden flag is absent from spawned argv - No live process, socket, or container is touched </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/session-execution.test.js</automated> </verify> <done>Execution-layer argv refusals proven.</done> </task>
</tasks>
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| sandbox process -> runner | Unbounded child output crosses into the daemon; the 262144-byte bound with truncation contains it |
| constructor options -> create | The egress list crosses into the policy write; constructor validation rejects it before any side effect |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-04-12 | D (Denial of Service) | src/runtime/session-execution.ts | high | mitigate | Exec output is bounded per stream with a truncation flag so a chatty sandbox process cannot exhaust daemon memory; the bound test pins it (D-94) |
| T-04-13 | D (Denial of Service) | src/runtime/session-execution.ts | high | mitigate | A timed-out exec kills the process tree in the runner's own timer and maps to TOOL_TIMEOUT, so no orphan keeps running in the background; the kill test pins it (D-91, D-94) |
| </threat_model> |
<verification> npx tsc -p tsconfig.json && node --test dist/test/runtime/session-execution.test.js; grep test/runtime/session-execution.test.ts for TOOL-03 and RT-05 in test titles. </verification>
<success_criteria> Bounded capture, error mappings with kill, egress plumbing, and execution-layer refusals all green with Phase 3 behavior preserved. </success_criteria>
Artifacts this phase produces (contribution of this plan)
spawnOpenShellRunnercapture path insrc/runtime/session-execution.ts(extended): boundedstdout/stderrwithtruncated, kinded error mapping, kill on timeout (D-94, C-6).SessionExecutionOptions.egressAllowwith constructorvalidateEgressAllow(new): earliest shared validation point (C-3).create()egress plumbing (extended): policy write receives the egress list andworkloadArgv[0]before the create argv (D-81).test/support/fake-openshell.ts(extended):stdout,stderr,truncatedon returns; kept argv andtimeoutMslogging.test/runtime/session-execution.test.ts(extended): TOOL-03 capture/mapping cases, RT-05 plumbing and refusal cases.
<output> Create .planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-05-SUMMARY.md when done </output>