Two distinct concerns live here and are easy to confuse: observation (what has been seen/read, and against which version) and enforcement (what a mutation is allowed to touch). dsh keeps them in separate packages that cooperate through the fs/* event seam and ctx.sandboxPolicy.
| Package | Role |
|---|---|
packages/fs/fs-observation-policy | Event-only tracking of observed files; derives write/edit intents |
packages/sandbox/sandbox-policy | ctx.sandboxPolicy: resolves each session's mode, workspace root, and overrides |
packages/fs/fs-sandbox | ctx.fs sandbox-enforcing implementation (SandboxedFileSystem) |
packages/fs/fs | ctx.fs service definition + fs/* event vocabulary |
packages/workspace/workspace | Durable workspace registry; canonical workspace roots |
Observation vs enforcement
| Concern | What it does | Where |
|---|---|---|
| Observation | Records authoritative presence/absence of a file, and the version it was read at | fs/observed events, folded by fs-observation-policy |
| Enforcement | Decides whether a mutation is permitted under the current mode/path policy | SandboxedFileSystem fence over writableRoots |
Observation is event-only: fs-observation-policy registers no service — it listens on the fs/* events emitted by the filesystem provider. Enforcement is a policy fence in the sandboxed provider.
The fs/* event seam
The @deepseek-ai/dsh-fs Service Definition (packages/fs/fs/src/index.ts) declares three events that the observation policy listens to:
| Event | Signature | Meaning |
|---|---|---|
fs/write-intent | (target, actor, next) => FsWriteIntent | Decide the write's freshness guard before writing |
fs/edit-intent | (target, actor, next) => { version } | Decide the edit's CAS version (must have been observed) |
fs/observed | (target, observation, actor): void | Record that a target was authoritatively seen (present or absent) |
The vocabulary is in packages/fs/fs/src/types.ts: FsTarget (opaque targetKey + displayPath), FsVersion (opaque freshness token), and FsObservation:
export type FsObservation =
| { readonly kind: 'present'; readonly version: FsVersion }
| { readonly kind: 'absent' }The observation policy gate (fs-observation-policy)
packages/fs/fs-observation-policy/src/index.ts builds an ObservedStateGate — a WeakMap<owner, Map<FsTargetKey, FsObservation>>, keyed first by the owner object (weakly held, so a collected session frees its state) and then by target key. The owner is derived from the event actor: normally the calling agent's session (FsObservationActor.agent.session). An un-ownable call (no agent) reads freely but can never satisfy the write/edit prior-observation policy.
Its three listeners hook the fs/* events: fs/write-intent and fs/edit-intent occupy the single decision slots of their intent waterfalls (they do not call next()), while fs/observed is an @mode emit recorder:
fs/write-intent→ unseen or confirmed-absent ⇒createIfAbsent; confirmed-present ⇒replaceIfVersionat the observed version.fs/edit-intent→ unseen rejects withFS_NOT_OBSERVED; confirmed absence rejects withFS_NOT_FOUND; presence supplies the observed version as the CAS basis.fs/observed→ records presence (with version) or absence. Must stay synchronous and non-throwing: mutations have already committed, andemitdoes not await promises.
Without this plugin, tools keep the bare provider's unconditional mutation behavior. The read-before-edit check therefore lives below tool-fs on the fs/* events — a model cannot silently edit a file it never read, or clobber one that changed out from under it. The README's composition rules pin this: it must be loaded as a sibling of the filesystem backend.
The enforcement fence (fs-sandbox)
SandboxedFileSystem (packages/fs/fs-sandbox/src/index.ts) extends LocalFileSystem and registers as ctx.fs. It inherits all text-storage mechanics (resolve, stat, read, list, atomic write, the read-match-write edit critical section) and adds a per-call policy fence on the two mutations — writeText and editText. Reads pass through untouched: every mode permits reading.
private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
const { mode } = policy
if (mode === 'danger-full-access') return target
if (mode === 'read-only') {
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
}
// workspace-write: containment on the FRESH canonical path ...
const fresh = await this.resolve(target.displayPath)
let contained = false
for (const root of writableRoots(policy)) {
if (await isPathUnder(fresh.targetKey, root)) { contained = true; break }
}
if (!contained) {
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
}
return fresh
}Key properties:
- The check is on the fresh canonical path, re-resolved immediately before delegating, and the mutation delegates with that target — narrowing the check-here-write-there TOCTOU (an ancestor symlink swapped between check and syscall is the accepted residual for this threat model).
- The writable-root set comes from the shared
writableRootshelper in@deepseek-ai/dsh-sandbox(packages/sandbox/sandbox/src/roots.ts): the canonicalized workspace root plus/tmpandos.tmpdir(). This is the same set the Seatbelt profile grants, so the fs fence and bash can never drift about whatworkspace-writepromises. - A denial throws the structured
FS_SANDBOX_DENIED— no stderr text inference is needed (unlike bash's kernel dialect), because an in-process fence knows exactly what it refused. The tool layer maps it to the model-facing[sandbox: …]marker and the escalation hint. - This is a policy check in trusted code over a model-controlled path, not a kernel boundary — containment, not a security backstop. Kernel isolation of untrusted code stays
ctx.shell's job.
How the policy reasons so the fence stays session-free
ctx.sandboxPolicy (packages/sandbox/sandbox-policy/src/index.ts) is the single owner of the mode + workspace root, resolved once per call (SandboxPolicyService.resolve({ session?, mode? })). The precedence: an approved explicit mode override → the session's last sandbox/mode event → the deployment default (read-only). The workspace-write root is the session's immutable cwd; the configured workspaceRoot is the fallback for agentless calls and sessions without a cwd.
The workspace the policy reasons about is the caller's session cwd, which the workspace registry (packages/workspace/workspace/src/index.ts, @deepseek-ai/dsh-workspace) treats as the durable, canonicalized boundary: each workspace is a canonical path with session membership validated against real realpath-normalized cwds. The sandbox policy consumes whichever of these roots applies to the current call rather than re-deriving its own — one canonical meaning, shared by enforcement and workspace accounting.
Data flow, one mutation
An ASCII tour of a write call under workspace-write:
model tool call: tool-fs/write(filePath, content)
→ tool-fs resolves a per-call policy via sandbox.resolvePolicy('write', …)
→ tool-fs calls ctx.fs.writeText(target, content, intent, signal, sandboxPolicy)
→ SandboxedFileSystem.checkedTarget:
read-only → throw FS_SANDBOX_DENIED
workspace-write → re-resolve fresh target, require containment under writableRoots
danger-full-access → delegate unfenced
→ inherited atomic writeText commits
→ tool-fs emits fs/observed(present/absent) → observation policy records itFor a bash write the path differs: ctx.sandbox.confine(['bash','-c', command], policy) wraps the whole argv in the kernel runner, and a denial is inferred from the runner's stderr dialect rather than raised by an in-process fence — while ctx.sandboxPolicy.resolve() supplies the identical mode + root to both.
Audit events
Observation emits fs/observed (authoritative presence/absence, model-visible as ordinary reads/writes in the session log). The policy gate is itself invisible to the model except through its effects (createIfAbsent vs replaceIfVersion intents and the FS_NOT_OBSERVED/FS_NOT_FOUND rejections). There is no separate audit surface for observation; the enforcement denials surface as FS_SANDBOX_DENIED mapped to the [sandbox: …] marker in the tool result.
Further reading
- Sandbox architecture: overview — the file-effect modes, the runner chain, and the escalation ladder.
- Permissions & approval — how
approveEscalationgrants a widermodefor the one call the fence then honors. - Landlock: the native runner — the file-effect grants that back the bash side of the same policy.
packages/fs/fs-observation-policy/src/index.ts— theObservedStateGateand its three listeners.packages/fs/fs-sandbox/src/index.ts— theSandboxedFileSystemmutation fence.packages/sandbox/sandbox-policy/src/index.ts—SandboxPolicyService.resolveand thesandbox/modeoverride fold.