<objective> Wire egress into the OpenShell layer: config-load refusals, the new policy-document signature, the pinned exec argv, and bounded runner capture.
Purpose: Deliver D-79, D-80, D-81, D-92, and the D-94/C-6 runner widening that the dispatcher and session-execution plans build on. Output: Extended openshell and config modules plus three test files. </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: Config-load refusals and absolute workload binary (D-79, D-80)</name> <files>src/runtime/config.ts, test/runtime/config.test.ts</files> <read_first> - src/runtime/config.ts lines 10-40 (ACCEPTED_KEYS, loadRuntimeConfig, and the unknown-key loop the denylist precedes) - src/runtime/config.ts lines 74-94 (requireArgv and requireAbsolutePath to copy for the element-0 check) - test/runtime/config.test.ts lines 98-106 and 134-139 (fixture and refusal-table style to extend) </read_first> <behavior> - Test (RT-05): each D-80 denylist name (network, networkMode, hostNetwork, netns, driverConfig, driverConfigJson) is rejected at load - Test (RT-05): a relative workloadArgv[0] is rejected (D-79); an absolute one is accepted - Test (RT-05): egressAllow entries are validated through validateEgressAllow at load - Test titles cite RT-05 and RT-01 </behavior> <action>Write the failing tests first per behavior, then extend src/runtime/config.ts per D-79 and D-80 without touching ACCEPTED_KEYS: check the named denylist before the unknown-key loop so the refusal is named in the test and the reason; add an absolute-path check on workloadArgv element 0 in requireArgv copying requireAbsolutePath, rejecting relative paths with SCHEMA_INVALID; validate egressAllow through validateEgressAllow from plan 04-01 at load. Update any Phase 3 fixture whose workloadArgv[0] is relative (D-79 requires it). (Implements D-79, D-80; no new policy key per D-101.)</action> <acceptance_criteria> - test/runtime/config.test.ts asserts every denylist name, the relative-path refusal, the absolute-path acceptance, and allowlist validation at load - No Phase 3 fixture still carries a relative workloadArgv[0] </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/config.test.js</automated> </verify> <done>Config-load refusals green with the named denylist and absolute-binary rule.</done> </task>
<task type="tdd"> <name>Task 2: Policy document through the egress renderer (D-81)</name> <files>src/runtime/openshell.ts, test/runtime/openshell-policy.test.ts</files> <read_first> - src/runtime/openshell.ts lines 168-208 (buildPolicyDocument, writePolicyDocument, and the line-205 call that must also take egress and the workload binary) - src/runtime/egress.ts (from plan 04-01; renderNetworkPolicies signature to call) - test/runtime/openshell-policy.test.ts lines 19-80 (existing empty-map assertion to keep per D-103, plus the buildPolicyDocument calls to update) </read_first> <behavior> - Test (RT-05): buildPolicyDocument(egress, workloadBinary) renders network_policies equal to the D-78 rendering for a given allowlist, and {} for an empty allowlist (the kept D-103 assertion) - Test (RT-05): version stays 1, filesystem_policy, landlock, and process are unchanged from D-57, the document parses as JSON, mode is 0600, and the write lands before the create argv - Test titles cite RT-05 </behavior> <action>Write the failing tests first per behavior, then change buildPolicyDocument() to buildPolicyDocument(egress, workloadBinary) per D-81, rendering network_policies through renderNetworkPolicies and leaving version, filesystem_policy, landlock, and process unchanged from D-57; update writePolicyDocument to take egress and the workload binary and pass them through (the pattern map notes this unnamed-but-required change). Keep the document JSON, atomic, mode 0600, and written before the create argv (RT-07 ordering). Keep the Phase 3 empty-allowlist assertion and extend it for non-empty (migrated per D-103, not deleted). (Implements D-81; OQ-P4-02 default: gateway acceptance of the document stays live-gated; OQ-P4-10 default: egress fixed at create.)</action> <acceptance_criteria> - test/runtime/openshell-policy.test.ts keeps the empty-map assertion and adds the non-empty D-78 equality, JSON parse, mode 0600, and write-before-create cases - No D-57 field value changes </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/openshell-policy.test.js</automated> </verify> <done>Policy document green with egress rendered and ordering preserved.</done> </task>
<task type="tdd"> <name>Task 3: buildExecArgv and the argv refusal list (D-92, D-80)</name> <files>src/runtime/openshell.ts, test/runtime/openshell-argv.test.ts</files> <read_first> - src/runtime/openshell.ts lines 72-116 (buildCreateArgv: id check, frozen argv, workload after --; the template for buildExecArgv) - src/session/paths.ts lines 37-42 (recordFileName id check applied before building realm-<sessionId>) - test/runtime/openshell-argv.test.ts line 27 (WORKLOAD fixture) and lines 117-121 (refusal-table style) </read_first> <behavior> - Test (RT-03, RT-05, AUD-02): buildExecArgv returns exactly openshell sandbox exec --name realm-<sessionId> --workdir /workspace --no-tty --no-login-shell -- <argv...> as a frozen array with the workload after -- - Test (RT-05): no argv the builders produce contains --env, --provider, --driver-config-json, --forward, --expose, --template, or --auto-providers - Test titles cite RT-03, RT-05, and AUD-02 </behavior> <action>Write the failing tests first per behavior, then add buildExecArgv(sessionId, argv) beside buildCreateArgv per D-92 with the same shape: recordFileName id check first, frozen returned array, workload after --, never emitting --env; pin the sandbox exec verb with the four flags --name, --workdir /workspace, --no-tty, --no-login-shell (D-93 evidence: local 0.1.2 sandbox exec --help read 2026-10-21). Extend the refusal test to assert every D-80 forbidden flag is absent from all argv the builders produce. (Implements D-92, D-93, D-80 argv half; OQ-P4-04 and OQ-P4-05 defaults: argv form is the tested path, remote exit code via CLI status, both live-gated.)</action> <acceptance_criteria> - test/runtime/openshell-argv.test.ts asserts the exact exec argv with deepEqual and the absence of every D-80 forbidden flag - The shell form (["/bin/sh", "-c", script]) is not built in this module </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/openshell-argv.test.js</automated> </verify> <done>Exec argv green with the exact pinned form and the refusal list.</done> </task>
<task type="auto"> <name>Task 4: Bounded runner capture without touching the create path (D-94, C-6, D-108)</name> <files>src/runtime/openshell.ts</files> <read_first> - src/runtime/openshell.ts lines 41-55 (OpenShellRunOptions and the OpenShellRunOutcome to widen) - src/runtime/session-execution.ts lines 241-275 (spawnOpenShellRunner: stdio ignore, RUNNER_FAILED and RUNNER_TIMED_OUT plain-Error rejections) - src/runtime/session-execution.ts line 208 (selectOpenShellEnvironment, which the doctor imports and which must keep working) </read_first> <action>Add to src/runtime/openshell.ts per C-6 (confirmed design): an opt-in captureOutput flag on OpenShellRunOptions; stdout and stderr fields on OpenShellRunOutcome each bounded at 262144 bytes with a truncated flag (D-94); and a small exported OpenShellRunnerError class carrying kind failed or timed-out with no child text in its message, replacing message-text matching (D-94 maps runner rejection to REALM_NOT_ACTIVE and deadline expiry to TOOL_TIMEOUT in plan 04-04). When captureOutput is false stdio stays ignore so the create path is unchanged. Only exec reads the captured fields; the create path ignores them. No test file is added here; plan 04-04 covers the behavior through the runner tests. (Implements D-94 type half and C-6.)</action> <acceptance_criteria> - OpenShellRunOutcome carries stdout, stderr, and truncated; OpenShellRunOptions carries opt-in captureOutput; OpenShellRunnerError carries kind with no child text - Default (captureOutput false) preserves stdio ignore behavior </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/openshell-argv.test.js</automated> </verify> <done>Runner types widened with capture opt-in and kinded errors.</done> </task>
</tasks>
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| config file -> runtime | Unknown keys and driver-escape names cross from user config; the denylist refuses them by name |
| runner output -> caller | Up to 262144 bytes per stream cross from the sandbox process; truncation is flagged, never silent |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-04-05 | E (Elevation of Privilege) | src/runtime/openshell.ts | high | mitigate | The argv builders never emit --driver-config-json, --forward, --expose, --template, --provider, --env, or --auto-providers; the refusal test asserts each absence so host-network or driver-config escape cannot be smuggled through config (D-80) |
| T-04-06 | E (Elevation of Privilege) | src/runtime/openshell.ts | high | mitigate | buildExecArgv pins the exact sandbox-exec form with no --env, so no host environment crosses into the sandbox (D-92, AUD-02); the shell form is not built here and stays behind checkExecPolicy in the dispatcher |
| </threat_model> |
<verification> npx tsc -p tsconfig.json && node --test dist/test/runtime/config.test.js dist/test/runtime/openshell-policy.test.js dist/test/runtime/openshell-argv.test.js; grep the three test files for RT-05 in test titles. </verification>
<success_criteria> Config refusals, rendered policy document, exact exec argv, and bounded capture types all green. </success_criteria>
Artifacts this phase produces (contribution of this plan)
buildExecArgv(sessionId, argv)insrc/runtime/openshell.ts(new): exact D-92 frozen argv (D-92, D-93).buildPolicyDocument(egress, workloadBinary)insrc/runtime/openshell.ts(changed signature):network_policiesthroughrenderNetworkPolicies(D-81).MANAGED_LABEL_VALUEmoved intosrc/runtime/openshell.ts(from plan 04-02 Task 1; recorded here as the shared-constant home).OpenShellRunOutcomewidened with boundedstdout,stderr,truncated;OpenShellRunOptions.captureOutput;OpenShellRunnerErrorwithkind(D-94, C-6).src/runtime/config.ts(extended): D-80 denylist, D-79 absoluteworkloadArgv[0], allowlist validation at load.test/runtime/openshell-argv.test.ts,test/runtime/openshell-policy.test.ts,test/runtime/config.test.ts(extended): RT-05 cases.
<output> Create .planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-03-SUMMARY.md when done </output>