<objective> Build the OpenShell adapter: the pinned create argv, the deny-all policy document with its atomic write, the idempotent bounded runner, and the scripted fake.
Purpose: Deliver the only path by which agent workload code starts (D-55 through D-57), keeping host environment out by construction. Output: Adapter module, OpenShell fake, and the argv and policy tests. </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/03-container-runtime-and-bridges/03-CONTEXT.md @.planning/phases/03-container-runtime-and-bridges/03-PATTERNS.md </context>
<tasks>
<task type="tdd"> <name>Task 1: Pinned argv builder and idempotent bounded runner</name> <files>src/runtime/openshell.ts, test/support/fake-openshell.ts, test/runtime/openshell-argv.test.ts</files> <read_first> - src/runtime/libpod-client.ts (from plan 03-04; listContainersByLabel for the pre-create lookup and the post-create poll) - src/core/errors.ts (OPENSHELL_START_FAILED catalogue entry; deadline expiry maps to it per D-56) - test/support/fake-execution.ts (call-log and scripted-result shape to mirror) - test/support/fake-libpod.ts (from plan 03-04; the shared container store the fake writes on successful create) </read_first> <behavior> - Test: deepEqual on the exact pinned argv openshell sandbox create --name realm-<sessionId> --policy <policyPath> --no-auto-providers --no-keep --detach --label io.realm.managed=true --label io.realm.session=<sessionId> --cpu <cpus> --memory <memory> -- <workloadArgv...> (D-55, 0.1.2 verbs per S-10) - Test: no --env and no --provider element in the argv (AUD-02); workload follows -- - Test: repeated create with the same session finds the labelled container and runs nothing a second time (CORE-06) - Test titles cite RT-03 and AUD-02 </behavior> <action>Write the failing tests first per behavior, then implement buildCreateArgv(input) and runOpenshellCreate in src/runtime/openshell.ts per D-55 and D-56: exact verb sequence above with no shell and no extra flags; create is idempotent by label by listing io.realm.session=<id> first and returning the existing id without running; otherwise run the argv through the injected runner and poll listContainersByLabel at 250 ms intervals until the container appears or the caller deadline expires with OPENSHELL_START_FAILED. Session id to sandbox name passes the SESSION_ID_PATTERN check from src/session/paths.ts. OQ-P3-01 (whether the driver applies --label) defaults to yes; a miss times out to OPENSHELL_START_FAILED and the gated suite decides. OQ-P3-12 (--no-keep with --detach semantics) stays recorded in the runtime note; verbs stay pinned. Then implement test/support/fake-openshell.ts recording argv arrays exactly with scripted per-call results, adding a container to the shared fake-libpod store on successful create.</action> <acceptance_criteria> - test/runtime/openshell-argv.test.ts pins the exact argv, the workload-after--- position, and both absences - Repeated create runs the runner exactly once (fake call count) </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/openshell-argv.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Argv pinned and idempotent runner green with host env excluded by construction.</done> </task>
<task type="tdd"> <name>Task 2: Deny-all policy document and atomic write</name> <files>src/runtime/openshell.ts, test/runtime/openshell-policy.test.ts</files> <read_first> - src/session/store.ts save (lines 27-40: mkdir 0700, bindRoot, observeSha256, atomicWriteFile with expectedPriorSha256) - src/fs/atomic-write.ts (TEMP_FILE_MODE 0o600 applied by fchmodSync; the policy file is therefore 0600 through this path) - docs/integration/openshell.md Q3 (policy schema: version 1, filesystem_policy, landlock, process, network_policies) </read_first> <behavior> - Test: JSON.parse round trip; version is 1; landlock.compatibility is hard_requirement; run_as_user is not root; network_policies is empty (RT-05 allowlist is Phase 4) - Test: file lands at <stateDir>/runtime/<sessionId>.policy.json with mode 0600 - Test titles cite RT-03 </behavior> <action>Write the failing tests first per behavior, then implement buildPolicyDocument(config) and writePolicyDocument in src/runtime/openshell.ts per D-57: plain object with version 1, filesystem_policy read_only [] and read_write ["/workspace"] (OQ-P3-02 default: /workspace in-sandbox path), landlock { compatibility: hard_requirement }, process { run_as_user: sandbox, run_as_group: sandbox }, network_policies {} with no endpoints (OQ-P3-16 default: no endpoints in Phase 3). Write as JSON (OQ-P3-15 default) through the atomic replace from src/fs/atomic-write.ts into <stateDir>/runtime/<sessionId>.policy.json at mode 0600. The document holds no secret and no environment. OQ-P3-03 (digest passing without an image verb) defaults to the D-51 verifier refusing mismatched digests; OQ-P3-14 (driver applying PID, read-only, cap-drop) defaults to fail-closed verification in plan 03-08.</action> <acceptance_criteria> - test/runtime/openshell-policy.test.ts asserts every field above plus the 0600 mode via statSync mode masking - Policy holds no secret, no environment, and no endpoint entry </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/openshell-policy.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Policy document green with deny-all egress and secure atomic write.</done> </task>
</tasks>
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| caller input -> argv builder | Workload argv crosses into process spawn; it must follow -- as positional args with no shell and no flag injection |
| host env -> sandbox | Host variables cross the sandbox boundary; --env and --provider are never emitted so none cross |
| policy object -> disk | Policy JSON crosses into the state dir; secrets must never be written into it |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-03-10 | I (Information Disclosure) | src/runtime/openshell.ts | high | mitigate | Builder never emits --env or --provider (AUD-02); argv tests assert both absences; policy document holds no secret or environment |
| T-03-11 | T (Tampering) | src/runtime/openshell.ts | medium | mitigate | Pinned verb sequence asserted by deepEqual; unknown openshell.md flags are never used; --policy path comes from the state dir, never caller text |
| T-03-12 | D (Denial of Service) | src/runtime/openshell.ts | low | mitigate | Create polls at 250 ms only until the caller deadline, then OPENSHELL_START_FAILED; repeated create reuses the labelled container |
| </threat_model> |
<verification> npx tsc -p tsconfig.json && node --test dist/test/runtime/openshell-argv.test.js dist/test/runtime/openshell-policy.test.js; grep both test files for RT-03 and AUD-02. </verification>
<success_criteria> Argv pinned with workload after -- and no env flags, policy deny-all at 0600, and idempotent create green. </success_criteria>
<output> Create .planning/phases/03-container-runtime-and-bridges/03-05-SUMMARY.md when done </output>