Certified Agent Harness Engineer
Course sections

M5.4

Filesystem policy

1,045 words · about 7 min

Concept

A filesystem fence answers: is this path inside the region this agent may touch? Naively a string prefix test — and it is not, because the path is model-controlled and the filesystem is a graph with aliases: .., symlinks, hard links, junctions, case-insensitivity, and platform aliases like macOS's /tmp → /private/tmp.

The correct primitive is canonicalize, then contain: resolve the path fully through the real filesystem, then test containment against a root canonicalized the same way. Comparing a canonical child against a non-canonical root is the same bug relocated.

Mechanism: three defences

Mechanism

Path traversal. workspace/../../.ssh/id_rsa is "inside" any lexical check that collapses .. symbolically before resolution. Resolve first, compare second.

Symlinks. A planted workspace/logs symlinked to /etc makes a write to workspace/logs/hosts inside the workspace by every string test and outside it in every way that matters. Canonicalization catches this because resolution follows the link.

TOCTOU. Between check and syscall, an ancestor directory can be replaced with a symlink. The check passed; the write lands elsewhere. This is the residual risk canonicalization cannot close.

The related primitive is no-follow metadata. docs/subsystems/filesystem.md describes lstat as "the path-level no-follow metadata primitive," noting consumers needing trust-boundary checks "can call lstat first and reject symlink before resolving." docs/defensive-patterns.md states the deletion rule: check lstatSync().isSymbolicLink() then use unlinkSync, because "unlink deletes only the link and refuses a real directory, so it never follows the link into its target." The Windows note shows why per-platform testing is not optional — rmSync(link) throws ERR_FS_EISDIR on a junction, and recursive deletion "may descend through one into its target."

Read/write asymmetry

Reads and writes deserve different policies because of reversibility plus blast radius. A read is non-destructive and its damage is bounded by what the agent does with the content afterward — governed elsewhere. A write is destructive, potentially irreversible, and realized immediately. So: reads broad, writes narrow. packages/fs/fs-sandbox/README.md states it flatly — "Reads always pass through — every mode permits reading."

This asymmetry is a deliberate accepted risk, not a free lunch. Unrestricted reads mean the agent can read ~/.aws/credentials into context, one exfiltrating call from disclosure. It is defensible only because separate controls — the scrub and egress posture of M5.7 and M5.5 — bound what can leave. Read policy and exfiltration policy are one system.

Worked example — an honest threat model

Worked example

The most valuable paragraph in dsh's filesystem story is packages/fs/fs-sandbox/README.md's section "Threat model: a policy fence, not a kernel boundary":

The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface.

The argument is precise: this is an in-process fence, so the code performing the syscall is trusted and only the path argument is attacker-influenced. Under that model canonicalize-then-contain really is complete — there is no second untrusted input. The README then names what it does not cover:

The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs openat2-class primitives not worth their portability cost here.

This is how to document a security control: state the model, prove completeness within it, name the residual risk, name the mechanism that would close it (openat2 with RESOLVE_BENEATH), and say why it was not adopted. A reader can now disagree on their own deployment's terms. Compare "the filesystem is sandboxed," which tells a reader nothing and invites misplaced trust.

The writable set derives from a single shared writableRoots (packages/sandbox/sandbox/src/roots.ts) so "the fs fence and the bash runner cannot drift" — the module's comment names the exact bug prevented: a "the write tool cannot write /tmp but bash can" asymmetry "cannot arise between them." Two enforcement points computing one boundary independently will diverge, and the divergence is a hole.

The containment test (packages/fs/fs-sandbox/src/containment.ts) shows what canonicalization really requires:

export async function isPathUnder(
  path: string, root: string,
  caseSensitive = process.platform !== 'win32',
): Promise<boolean>

The case-sensitivity default is platform-derived, because on Windows and macOS a case-varied path is the same file and a case-sensitive comparison would let WORKSPACE/../etc slip a check that workspace/../etc fails. After a lexical fast path it falls back to a device/inode identity walk, comparing dev/ino rather than strings, treating ENOENT/ENOTDIR as "missing" rather than error. Identity comparison catches hard links and bind mounts, which alias one file object under two names no string canonicalization will reconcile — which is also why packages/sandbox/sandbox-local/README.md can only report Windows enforcement as partial.

Tradeoffs

Canonicalization costs a syscall per check and fails on paths that do not yet exist (a create must canonicalize the parent). Strict containment breaks legitimate work: monorepo tooling reaching a sibling, a global cache under ~, a temp directory outside the workspace. Every harness ends with an exception list, and every exception is a hole someone argued for.

How `dsh` does it

How dsh does it

dsh distinguishes error causes rather than collapsing them: FS_SANDBOX_DENIED means policy refused, FS_PERMISSION_DENIED means the host kernel refused — different bugs, different fixes — and the policy code is emitted structurally because "an in-process fence knows exactly what it refused" and needs no stderr inference. A read-before-write observation policy fails an edit to an unobserved file with FS_NOT_OBSERVED: a correctness control preventing blind clobbering that doubles as a safety control forbidding "write a file you never looked at."

The candid note in docs/subsystems/filesystem.md deserves flagging: single-slot decision waterfalls are "first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant," and without the policy plugin mounted, write and edit are unconditional. Enforcement is composition-dependent, and the docs say so rather than implying the fence is always present.

How others differ

Claude Code enforces a working-directory boundary in-process with additional directories opt-in — structurally similar. Codex CLI pushes the boundary down to the OS sandbox so fence and kernel agree by construction, with no policy-fence gap to document: cleaner, and costing portability since it needs a working sandbox on every OS. Container harnesses sidestep path policy by making the container filesystem the workspace, moving the problem to image and mount configuration.


Check yourself

4 questions on M5.4

Start drill