Skip to content

<objective> Policy loading with defaults and mode decision, the shared display mapping, timeout helper, and the exec argv-or-shell gate. Runs in wave 1 parallel with 02-01; files are disjoint and policy.ts takes no dependency on 02-01 files.

Purpose: Give every later layer the validated policy value, the fail-closed decision, the two display labels, and the timeout wrapper the lifecycle budget depends on. Output: Three source files and five test files covering CORE-02, CORE-03, and TOOL-02.

Decisions implemented: D-21: policy file location (explicit path); D-22: policy file not inside its roots and absolute roots; D-23: mode decision; D-24: exec policy gate; D-27: display mapping; D-28: timeout wrapper. </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/02-policy-state-and-filesystem-lockdown/02-CONTEXT.md @.planning/phases/02-policy-state-and-filesystem-lockdown/02-PATTERNS.md </context>

<tasks>

<task type="checkpoint:decision"> <name>Checkpoint: confirm the policy file format (D-15 one-way)</name> <files></files> <read_first> - .planning/phases/02-policy-state-and-filesystem-lockdown/02-CONTEXT.md (D-15 keys and defaults, D-21 explicit-path rule) - src/core/policy.schema.json (frozen keys: mode, workspaceRoots, credentialAllow, egressAllow, shellEnabled, startupBudgetMs, toolQueueMs) </read_first> <action>Confirm before the loader lands: the policy file is an explicit caller-supplied path with no default location, a missing file means the default policy applies, invalid content fails through validate policy with SCHEMA_INVALID, and the schema file itself stays frozen. Undo after projects author files in this format needs a migration, which is why this gate exists.</action> <verify> <automated>MISSING — human decision required before the policy loader lands</automated> </verify> <done>Human confirms the D-15 file format and explicit-path rule, or records an amendment.</done> </task>

<task type="tdd"> <name>Task 1: Policy loader, mode decision, and exec gate</name> <files>src/core/policy.ts, test/core/policy.test.ts, test/core/fail-closed.test.ts, test/contract/exec-policy.test.ts</files> <read_first> - src/contract/validate.ts (Policy type, validate policy with defaults on a copy, SchemaInvalidError shape) - src/core/errors.ts (POLICY_DENIED, SCHEMA_INVALID, SHELL_NOT_ALLOWED, REALM_UNBOUND, REALM_STOPPING, REALM_NOT_ACTIVE catalogue entries) - src/contract/tools.schema.test.ts (exec argv and shell acceptance cases already covered; do not duplicate them) </read_first> <behavior> - Test: missing file yields defaults (advisory, empty roots, shellEnabled false, startupBudgetMs 10000, toolQueueMs 2000 pinned as literals); unknown keys rejected SCHEMA_INVALID; each workspaceRoots entry must be absolute or SCHEMA_INVALID naming the key - Test: policy file inside its own workspaceRoots rejected POLICY_DENIED; unresolvable root fails the bind POLICY_DENIED - Test: decideMutation allow in active; allow-with-warning under advisory non-active with state-naming text; deny under fail-closed with REALM_UNBOUND for unbound, REALM_STOPPING for stopping, REALM_NOT_ACTIVE for connecting, degraded, error - Test: argv form allowed; shell form rejected SHELL_NOT_ALLOWED unless shellEnabled true; shell allowed when true; gate never spawns a process - Test titles cite CORE-03 (loader, fail-closed) and TOOL-02 (exec gate) </behavior> <action>Write the failing tests first per behavior, then implement in src/core/policy.ts: loadPolicyFile(explicitPath) returning the validated Policy, where a missing file returns validate policy defaults on empty input, an invalid file propagates the wrapper SchemaInvalidError, a relative workspaceRoots entry returns a SchemaInvalidError in the wrapper shape with the key named (F-3 code check, never a schema edit, and never echoing a value per the privacy rule), and a policy file inside its own workspaceRoots throws makeRealmError POLICY_DENIED after comparing canonical path segments (flag as costly per D-22: the agent it constrains can rewrite files under the roots); decideMutation(policy, state) as a pure function returning allow, allow-with-warning with the state-naming warning text, or deny with the D-23 code mapping; checkExecPolicy(policy, exec) accepting the validated argv form and throwing SHELL_NOT_ALLOWED for shell form unless policy.shellEnabled, with no child_process import anywhere in the file. Resolve F-1 here and record it: policy.ts imports validate from contract with types via import type and runtime use only inside function bodies, never at module-eval time, so the core-to-contract edge stays safe beside the existing contract-to-core edge; D-20 is amended to permit validation-only core imports of contract. Keep F-10 shapes: the loader returns plain Policy values, never the ok wrapper.</action> <acceptance_criteria> - test/core/policy.test.ts asserts defaults, SCHEMA_INVALID on unknown keys and relative roots, POLICY_DENIED for inside-roots, and defaults on missing file - test/core/fail-closed.test.ts covers decideMutation for all six states under both modes with the exact deny codes - test/contract/exec-policy.test.ts asserts argv allowed, shell rejected SHELL_NOT_ALLOWED unless enabled, and no execution (no child_process import in src) </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/core/policy.test.js dist/test/core/fail-closed.test.js dist/test/contract/exec-policy.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Loader, mode decision, and exec gate exist with tests green; schema files untouched.</done> </task>

<task type="tdd"> <name>Task 2: Display mapping and timeout helper</name> <files>src/core/status.ts, src/core/timeout.ts, test/core/status.test.ts, test/core/timeout.test.ts</files> <read_first> - src/core/states.ts (State union the mapping switches over) - src/core/errors.ts (makeRealmError; withTimeout rejects with the caller-supplied catalogue code) </read_first> <behavior> - Test: all six states map to exactly one of the two labels; degraded returns active with degraded true; unbound, connecting, stopping, error return disconnected with reasons no workspace, connecting, stopping, and the error code; reason and lastTransitionAt pass through - Test: withTimeout resolves with the value, rejects with RealmError of the given code on expiry, and clears its timer on settle (no hanging handles) - Test titles cite CORE-02 (status) and QUAL-01 (timeout enforcement) </behavior> <action>Write the failing tests first per behavior, then implement: displayStatus(snapshot) in src/core/status.ts as one exported pure function returning label from a single frozen STATUS_LABELS pair plus degraded boolean, reason, and lastTransitionAt, implementing exactly the D-27 rows including degraded-is-active-with-flag and stopping-carried-by-reason (flag as costly per D-27: both labels are user-visible and every host imports this mapping); withTimeout(promise, ms, code) in src/core/timeout.ts racing the promise against a timer, clearing the timer on settle, and rejecting with makeRealmError(code) on expiry. No other display strings exist in the repo after this task.</action> <acceptance_criteria> - test/core/status.test.ts pins both label literals and asserts one-label-per-state for all six states - test/core/timeout.test.ts asserts resolve, code-carrying reject on expiry, and no open handle after settle - grep for the label strings finds them defined once in src/core/status.ts </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/core/status.test.js dist/test/core/timeout.test.js; grep -c 'realm active' src/core/status.ts</automated> <fails_when>non-zero exit, 0 passed in the summary, or the grep count prints 0</fails_when> </verify> <done>Shared mapping and timeout helper exist with tests green and labels defined once.</done> </task>

</tasks>

<threat_model>

Trust Boundaries ​

BoundaryDescription
policy file -> loaderAgent-writable files cross into the constraining policy; location and content are both untrusted
tool input -> exec gateValidated exec params cross into the shell decision; unvalidated input must never reach it

STRIDE Threat Register ​

Threat IDCategoryComponentSeverityDispositionMitigation Plan
T-02-03T (Tampering)src/core/policy.tshighmitigateLoader rejects policies inside their own workspaceRoots with POLICY_DENIED (D-22); doctor policy-file check fails it with a move fix
T-02-04E (Elevation of Privilege)src/core/policy.tsmediummitigateRelative workspaceRoots rejected with SCHEMA_INVALID by code check after validation (F-3); absolute-only invariant enforced before any bind
</threat_model>

<verification> npx tsc -p tsconfig.json && node --test dist/test/core/policy.test.js dist/test/core/fail-closed.test.js dist/test/contract/exec-policy.test.js dist/test/core/status.test.js dist/test/core/timeout.test.js; grep the five test files for their requirement IDs. </verification>

<success_criteria> Policy defaults, mode decision for all states under both modes, exec gate, two-label display, and timeout helper all green with schema files untouched. </success_criteria>

<output> Create .planning/phases/02-policy-state-and-filesystem-lockdown/02-02-SUMMARY.md when done </output>

Artifacts this phase produces ​

  • src/core/policy.ts (loadPolicyFile with D-21 and D-22 rules, decideMutation per D-23, checkExecPolicy per D-24; F-1 layering resolution recorded)
  • src/core/status.ts (displayStatus plus the single frozen label pair per D-27)
  • src/core/timeout.ts (withTimeout per D-28)
  • test/core/policy.test.ts (CORE-03: defaults, SCHEMA_INVALID cases, relative-root rejection, inside-roots POLICY_DENIED, missing-file defaults)
  • test/core/fail-closed.test.ts (CORE-03 plus the n5 silent-fallback mitigation: decideMutation for every state under both modes)
  • test/contract/exec-policy.test.ts (TOOL-02: argv allowed, shell gated, never executes)
  • test/core/status.test.ts (CORE-02: one of two labels per state, degraded flag, reason and time carried)
  • test/core/timeout.test.ts (QUAL-01: resolve, code-carrying expiry, timer cleared)

AGPL-3.0-only. Copyright (C) 2026 Spectrum Web Co/swcstudiospace.