Skip to content

<objective> Filesystem enforcement layer: glob matcher, credential denial, advisory locks, atomic-write failure coverage, the locked write composition, capped read and list, and the swapped-symlink race test. Depends on 02-01 for the canonicalizer, atomic replace, and hook seam.

Purpose: Close FS-01 through FS-05 with the D-34 write order proven and the FS-02 race rejected on real fixtures. Output: Six source files and eight test files; the write path order and every failure mode green. </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="tdd"> <name>Task 1: Glob matcher and credential denial list</name> <files>src/fs/glob.ts, src/fs/credentials.ts, test/fs/glob.test.ts, test/fs/deny-credentials.test.ts</files> <read_first> - src/core/states.ts (freezeEdges pattern for the frozen credential constant per pattern map section 2) - src/core/errors.ts (CREDENTIAL_PATH_DENIED catalogue entry) </read_first> <behavior> - Test: star within a segment, question-mark single character, double-star across zero or more whole segments including at root and mid-pattern - Test: each D-31 denied class denied; credentialAllow globs permit; denial checked after escape so an escaping credential path reports PATH_ESCAPE - Test titles cite FS-03; matcher cases cite D-32 </behavior> <action>Write the failing tests first per behavior, then implement: matchGlob(pattern, relPath) in src/fs/glob.ts supporting star within a segment, question-mark for one character, and double-star for zero or more whole segments with no dependency and no path.matchesGlob; CREDENTIAL_PATTERNS in src/fs/credentials.ts as a single frozen constant holding exactly the D-31 list, plus isCredentialDenied(canonicalRelPath) and isCredentialAllowed(patterns, canonicalRelPath) helpers that match against the canonical relative path after symlink resolution, where policy credentialAllow globs override a denial and denial is always evaluated after the PATH_ESCAPE check. This task covers the matcher-level denial cases; read and write integration lands in Task 3 of this plan, which appends to test/fs/deny-credentials.test.ts.</action> <acceptance_criteria> - test/fs/glob.test.ts covers star, question-mark, and double-star at root, middle, and trailing positions - test/fs/deny-credentials.test.ts denies every D-31 class, permits via credentialAllow, and asserts escape-before-credential ordering - No new dependency appears in package.json </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/fs/glob.test.js dist/test/fs/deny-credentials.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Matcher and denial list exist with unit tests green; read and write wiring follows in Task 3.</done> </task>

<task type="tdd"> <name>Task 2: Advisory lock table, capped read, and reported list</name> <files>src/fs/locks.ts, src/fs/read.ts, src/fs/list.ts, test/fs/lock.test.ts, test/fs/read-list.test.ts</files> <read_first> - src/fs/canonicalize.ts (from plan 02-01; read and list canonicalize first, then check credentials) - src/fs/credentials.ts (from Task 1 of this plan; denial runs after canonicalization) - src/core/errors.ts (LOCK_CONFLICT and PATH_ESCAPE entries) </read_first> <behavior> - Test: conflicting owner fails LOCK_CONFLICT with the file byte-identical; same-owner re-acquire idempotent returning the same handle; release idempotent; second owner reaching the same file through a symlink conflicts - Test: read returns found with content, sha256, and truncated flag at maxBytes, absent for missing final segment, PATH_ESCAPE for non-regular files and for credential paths - Test: list reports symlinks with target and never follows them, caps at 1000 entries with truncated set - Test titles cite FS-04 (locks) and FS-01 with FS-03 (read and list) </behavior> <action>Write the failing tests first per behavior, then implement per D-33 and D-36: LockTable in src/fs/locks.ts keyed by canonical real path with acquire(realPath, owner) returning a handle or throwing LOCK_CONFLICT for another owner, idempotent re-acquire by the same owner, and idempotent release; readFileCapped in src/fs/read.ts canonicalizing first, applying the credential check, rejecting non-regular files with PATH_ESCAPE, reading at most maxBytes with truncated set when more exists, returning the found or absent status union (never the validate ok shape per F-10); listDir in src/fs/list.ts with the same canonicalize-then-deny order, reporting entries with name, kind of file, dir, symlink, or other, plus target for symlinks without following them, capping at 1000 entries with truncated. Session-destroy lock release is asserted in plan 02-05 against the controller, not here, so this task takes no dependency on lifecycle files.</action> <acceptance_criteria> - test/fs/lock.test.ts asserts conflict leaves the file unchanged, idempotent acquire and release, and the through-symlink second-owner conflict - test/fs/read-list.test.ts asserts truncation flags, non-regular rejection, symlink reporting without following, and the 1000-entry cap - Locks stay in memory only; no persistence file is created </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/fs/lock.test.js dist/test/fs/read-list.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Lock table, capped read, and reported list exist with tests green.</done> </task>

<task type="tdd"> <name>Task 3: Locked write composition and atomic-write failure coverage</name> <files>src/fs/write.ts, test/fs/write.test.ts, test/fs/atomic-write.test.ts</files> <read_first> - src/fs/atomic-write.ts (from plan 02-01; the stageHook seam this task drives) - src/fs/locks.ts (from Task 2 of this plan; acquire before observe, release in finally) - test/fs/deny-credentials.test.ts (from Task 1 of this plan; append the read and write denial cases here) </read_first> <behavior> - Test: ifMatchSha256 mismatch and absent-prior both fail LOCK_CONFLICT; hash re-check before rename yields LOCK_CONFLICT with the file unchanged (non-cooperating writer case) - Test: stageHook failures at after-temp-write, after-fsync, and before-rename each leave the original byte-identical, leave no .realm-tmp- file behind, and report WRITE_NOT_ATOMIC; the success path replaces content - Test: denied credential paths fail on write; escaping paths report PATH_ESCAPE </behavior> <action>Write the failing tests first per behavior, then implement lockedWrite in src/fs/write.ts in exactly the D-34 order: canonicalize in write mode with parent present and inside the root and symlink-at-target rejected PATH_ESCAPE, then credential check, then lock acquire mapping failure to LOCK_CONFLICT with the file untouched, then observe current content hash or absence, then ifMatchSha256 comparison failing LOCK_CONFLICT on mismatch including absent, then atomicWriteFile with the observed hash as expected prior, then lock release in a finally; drive the D-35 failure cases through the stageHook parameter plan 02-01 created. Append the read and write credential-denial integration cases to test/fs/deny-credentials.test.ts from Task 1 of this plan.</action> <acceptance_criteria> - test/fs/write.test.ts asserts the D-34 order effects: mismatch, absent, and re-check races all LOCK_CONFLICT with unchanged files - test/fs/atomic-write.test.ts asserts byte-identical originals and zero .realm-tmp- residue for all three stage hooks plus the success path - test/fs/deny-credentials.test.ts covers denial on read and on write paths </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/fs/write.test.js dist/test/fs/atomic-write.test.js dist/test/fs/deny-credentials.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Locked write and failure injection coverage green with the D-34 order intact.</done> </task>

<task type="auto"> <name>Task 4: Swapped-symlink race test through the boundary hook</name> <files>src/fs/canonicalize.ts, test/fs/toctou.test.ts</files> <read_first> - src/fs/canonicalize.ts (from plan 02-01; the onResolveBoundary hook seam this task drives) - .planning/phases/02-policy-state-and-filesystem-lockdown/02-CONTEXT.md (D-30 resolve-then-verify steps and the Q-2 rename-window residual risk) </read_first> <action>Edit only the hook wiring if the seam from plan 02-01 needs a broader boundary, otherwise add no production code: write test/fs/toctou.test.ts driving a real temporary directory where the onResolveBoundary hook swaps a symlink between resolve and open, asserting the open is rejected with PATH_ESCAPE; assert fail-closed behavior where /proc/self/fd is unavailable with no skip of any kind per F-8. Record the Q-2 residual risk (parent-directory rename between verification and rename narrowed by the parent re-check but not closable with path-based Node APIs; Phase 7 security review owns the verdict) as a comment-adjacent test title note and in the plan summary, not as a code TODO.</action> <acceptance_criteria> - test/fs/toctou.test.ts swaps a symlink at the resolve-to-open boundary and asserts PATH_ESCAPE - No skip, todo, or platform gate exists in the test; failure is a real failure - Q-2 residual risk recorded in the test narrative and the plan summary </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/fs/toctou.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed or any skipped</fails_when> </verify> <done>Race rejection proven on a real fixture with fail-closed semantics and the residual risk recorded.</done> </task>

</tasks>

<threat_model>

Trust Boundaries ​

BoundaryDescription
symlink -> openA symlink swapped between check and open crosses into the verified descriptor
writer -> lock tableNon-cooperating writers bypass advisory locks; the hash re-check is the backstop
credential path -> sandboxCredential-shaped content crossing into logs or reads is the exfiltration path

STRIDE Threat Register ​

Threat IDCategoryComponentSeverityDispositionMitigation Plan
T-02-05T (Tampering)src/fs/canonicalize.tshighmitigateO_NOFOLLOW open with fstat dev and ino comparison and fd readlink check; toctou test drives the swap; Q-2 rename-window residual recorded, not hidden
T-02-06I (Information Disclosure)src/fs/credentials.tshighmitigateDenial matched on the canonical relative path after symlink resolution; escaping credential paths report PATH_ESCAPE first so the escape is never masked
T-02-07T (Tampering)src/fs/write.tsmediummitigateLock conflict plus pre-rename hash re-check both yield LOCK_CONFLICT with the file unchanged; non-cooperating writers are caught at rename
</threat_model>

<verification> npx tsc -p tsconfig.json && node --test dist/test/fs/glob.test.js dist/test/fs/deny-credentials.test.js dist/test/fs/lock.test.js dist/test/fs/read-list.test.js dist/test/fs/write.test.js dist/test/fs/atomic-write.test.js dist/test/fs/toctou.test.js; grep the test files for their requirement IDs. </verification>

<success_criteria> FS-01 through FS-05 green: escapes rejected, races rejected, credentials denied, lock conflicts leave files unchanged, and interrupted writes leave byte-identical originals with no temp residue. </success_criteria>

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

Artifacts this phase produces ​

  • src/fs/glob.ts (matchGlob: star, question-mark, double-star; no dependency)
  • src/fs/credentials.ts (CREDENTIAL_PATTERNS frozen constant plus denied or allowed helpers on the canonical relative path)
  • src/fs/locks.ts (LockTable: idempotent acquire and release keyed by canonical real path)
  • src/fs/read.ts (readFileCapped: found or absent union, maxBytes truncation, non-regular rejection)
  • src/fs/list.ts (listDir: symlink reporting without following, 1000-entry cap)
  • src/fs/write.ts (lockedWrite in the exact D-34 order with finally release)
  • test/fs/glob.test.ts (D-32 matcher cases)
  • test/fs/deny-credentials.test.ts (FS-03: every denied class on read and write, allowlist permits, escape-first ordering)
  • test/fs/lock.test.ts (FS-04: conflict unchanged, idempotence, through-symlink conflict)
  • test/fs/read-list.test.ts (D-36: truncation, rejection, symlink reporting, cap)
  • test/fs/write.test.ts (D-34 order: mismatch, absent, re-check race)
  • test/fs/atomic-write.test.ts (FS-05: three stage-hook failures plus success path)
  • test/fs/toctou.test.ts (FS-02: swapped-symlink rejection, fail-closed, Q-2 recorded)

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