Plan 02-03 Summary: Filesystem enforcement (glob, credential denial, locks, capped read and list, locked write, race test)
Status: implemented; gates not run in this lane (the orchestrator runs tsc, lint, and node --test after the wave). Requirements claimed: FS-01, FS-02, FS-03, FS-04, FS-05.
Files
Created or changed (repo-relative):
src/fs/glob.ts:matchGlob(pattern, relPath).*within a segment,?one character, whole-segment**. Hand-written, memoized, no dependency (D-32).src/fs/credentials.ts:CREDENTIAL_PATTERNS(frozen, exactly the 20 D-31 shapes),isCredentialDenied,isCredentialAllowed,assertCredentialAccess(throwsCREDENTIAL_PATH_DENIED),relPathOf(canonical relative path of a canonicalize result, absent results included).src/fs/locks.ts:LockTablewithacquire,release,isHeldBy,releaseOwner(in memory; keyed by canonical real path).src/fs/read.ts:readFileCapped, returning thefoundorabsentunion.src/fs/list.ts:listDir, returning thefoundorabsentunion, withMAX_LIST_ENTRIES = 1000.src/fs/write.ts:lockedWritein the D-34 order with afinallyrelease.test/fs/glob.test.ts,test/fs/deny-credentials.test.ts,test/fs/lock.test.ts,test/fs/read-list.test.ts,test/fs/write.test.ts,test/fs/atomic-write.test.ts,test/fs/toctou.test.ts..planning/phases/02-policy-state-and-filesystem-lockdown/02-03-SUMMARY.md(this file).
Not changed: src/fs/canonicalize.ts, src/fs/atomic-write.ts, src/fs/root.ts (the 02-01 base is used as-is). No frozen file, no package.json, no other .planning/ file was touched. No dependency was added.
Must-haves and the tests that evidence them
- A symlink swapped in between resolve and open is rejected with
PATH_ESCAPEvia the boundary hook:a symlink swapped in at the resolve-to-open boundary is rejected with PATH_ESCAPE (FS-02). Alsoa directory on the resolved path swapped for an escaping symlink is caught by the /proc/self/fd check (FS-02, D-30), which shows the descriptor readlink check is what catches an intermediate-directory swap. - Every denied credential shape fails with
CREDENTIAL_PATH_DENIEDon read and on write unlesscredentialAllowpermits; escapes reportPATH_ESCAPEfirst:every denied shape fails with CREDENTIAL_PATH_DENIED on read (FS-03),... on write and leaves the file unchanged (FS-03),credentialAllow permits the matching denied path for read and write and nothing else (FS-03),an escaping credential path reports PATH_ESCAPE before any credential check (FS-01, FS-03). - A conflicting lock fails with
LOCK_CONFLICTleaving the file unchanged; same-owner re-acquire is idempotent; release is idempotent; a second owner through a symlink conflicts:a conflicting owner gets LOCK_CONFLICT and the file stays byte-identical (FS-04),same-owner re-acquire is idempotent and returns the same handle (FS-04),release is idempotent and frees the path for another owner (FS-04),a second owner reaching the same file through a file symlink conflicts (FS-04),a second owner writing through a directory symlink conflicts and the file is unchanged (FS-04). - Each atomic-write stage-hook failure leaves the original byte-identical with no
.realm-tmp-file and reportsWRITE_NOT_ATOMIC:a stage-hook failure at after-temp-write | after-fsync | before-rename leaves the original byte-identical with no temp file and reports WRITE_NOT_ATOMIC (FS-05). The success path:the success path replaces the content and leaves no temp file (FS-05).
Plan task acceptance criteria:
- Task 1:
test/fs/glob.test.tscovers star, question mark, and double star at root, middle, and trailing positions.test/fs/deny-credentials.test.tsdenies every D-31 class, permits viacredentialAllow, and asserts escape-before-credential ordering. No new dependency inpackage.json. - Task 2:
test/fs/lock.test.tsasserts conflict unchanged, idempotent acquire and release, and the through-symlink conflict.test/fs/read-list.test.tsasserts truncation, non-regular rejection, symlink reporting without following, and the 1000-entry cap. No persistence file is created (locks live in memory and acquire and release create no file (FS-04)). - Task 3:
test/fs/write.test.tscovers theifMatchSha256mismatch and absent cases and the pre-rename re-check race.test/fs/atomic-write.test.tscovers all three stage hooks plus success.test/fs/deny-credentials.test.tscovers read and write denial. - Task 4:
test/fs/toctou.test.tsswaps a symlink at the boundary and assertsPATH_ESCAPE. There is no skip, todo, or platform gate in any test file. The fail-closed case iswithout /proc/self/fd the verified read fails closed with PATH_ESCAPE and is never skipped (FS-02, D-30), with a positive controlthe same read succeeds with /proc/self/fd present ... (FS-02).
Requirement evidence
- FS-01 (canonical paths, escapes):
test/fs/read-list.test.ts(read returns the content...,list reports each entry's kind...),test/fs/write.test.ts(a target reached through an escaping directory symlink...,a symlink at the target...), andtest/fs/deny-credentials.test.ts(an escaping credential path...). - FS-02 (race rejection):
test/fs/toctou.test.ts, all four tests. - FS-03 (credential denial):
test/fs/deny-credentials.test.ts, all twelve tests, includingthe denied list is exactly the D-31 shapes..., which pins the list as literals. - FS-04 (advisory locks, locked write):
test/fs/lock.test.ts, all nine tests;test/fs/write.test.ts, all thirteen tests, including the D-34 order effects (an ifMatchSha256 that differs...,an ifMatchSha256 against an absent target...,a non-cooperating writer between observation and rename...). - FS-05 (atomic replace):
test/fs/atomic-write.test.ts, all five tests. - D-32 matcher cases:
test/fs/glob.test.ts, all eight tests. - D-36 read and list:
test/fs/read-list.test.ts, all seven tests.
Deviations and interpretations (each with the default applied)
atomic-write.tsnot edited. It is not in this plan'sfiles_modified. The stage-hook matrix is driven from tests with a plainErrorthrown from the hook, which the existing catch maps toWRITE_NOT_ATOMICwithreplaced: false. ARealmErrorthrown from a hook propagates unchanged. Production callers pass no hook, so this is a test-seam property only.- Missing parent on write is
PATH_ESCAPE. D-34 step 1 requires the parent to exist, but names no code for its absence. The default applied isPATH_ESCAPE, since an unverifiable path is treated as escaping (D-30). Covered bya missing parent directory is PATH_ESCAPE and creates nothing (D-34 step 1). LockTable.releaseOwneradded. D-33 says session destroy releases every lock held for that session, and plan 02-05 (destroy releases the session locks via the D-33 table) calls it. It is inlocks.ts, which is in this plan's scope. Covered byreleasing an owner's locks frees every path it held (FS-04, D-33).LockTable.isHeldByadded; a write does not drop a lock its owner took earlier. Without this,lockedWriteby an owner that already held the path would release that lock in itsfinally. Covered bya locked write by the lock's own owner keeps the lock it took before (FS-04).readsha256 covers the whole file. Content is capped atmaxBytes, but the hash keeps streaming past the cap, so it matchesobserveSha256, which the locked write uses forifMatchSha256.truncatedistotal > maxBytes.- Read content is
Uint8Array(bytes), not a string. A capped read can cut a UTF-8 sequence, so bytes are the honest type. - Credential check covers absent paths. The check runs on the canonical path of an absent result too, so a denied shape that does not yet exist is denied on read and write. Covered by
a denied shape that does not exist yet is denied on read and on write (FS-03). listof a non-directory, or of an unreadable directory, isPATH_ESCAPE. No catalogue code names this; the default applied is the same fail-closed rule as the canonicalizer.credentialAllowis required on the read, list, and write options. It has no default, so a caller cannot skip the policy silently.- Credential matching is case-sensitive, as the Linux filesystems this phase targets are.
.ENVis not denied by the.envshape. - Credential check runs before the resolve-to-open hook in
read, and the verified open confirms the descriptor's real path equals the checked path (the/proc/self/fdreadlink check), so a swap after the check cannot redirect the open to an unchecked object.
Residual risks and open questions
- Q-2 (rename window, accepted). Node 22 has no
openat. A parent directory swapped between verification andrename(2)is narrowed by the parent re-check, not closed. Recorded in the header oftest/fs/toctou.test.tsand here, for the Phase 7 security review. - FIFO open race (not closed here).
readrejects FIFOs and other non-regular files before the open, so a FIFO present at check time never blocks. A FIFO swapped in between that check and theO_RDONLYopen would block the open. Closing it needsO_NONBLOCKinopenVerified(02-01 module, outside this plan's changes). Flagged for the Phase 7 review. - Q-3 (orphaned temp files, default applied). A temp file left by a killed process is not cleaned up by this plan.
- Q-4 (MCP and ACP contract tests, deferred to Phase 5). No MCP or ACP test was added. QUAL-02 coverage is through the taxonomy tests in plan 02-06.
- Not verified in this lane. Per the shared rules, I did not run
tsc, lint, ornode --test. Items the orchestrator should watch on the compiler pass: theDirenttype import andreaddirSyncwithFileTypes return inference insrc/fs/list.ts; theObject.freezetyping ofLockHandle;.someon the frozenas constcredential tuple; and theBufferandUint8Arrayassignments insrc/fs/read.ts.