Skip to content

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.

PackageRole
packages/fs/fs-observation-policyEvent-only tracking of observed files; derives write/edit intents
packages/sandbox/sandbox-policyctx.sandboxPolicy: resolves each session's mode, workspace root, and overrides
packages/fs/fs-sandboxctx.fs sandbox-enforcing implementation (SandboxedFileSystem)
packages/fs/fsctx.fs service definition + fs/* event vocabulary
packages/workspace/workspaceDurable workspace registry; canonical workspace roots

Observation vs enforcement

ConcernWhat it doesWhere
ObservationRecords authoritative presence/absence of a file, and the version it was read atfs/observed events, folded by fs-observation-policy
EnforcementDecides whether a mutation is permitted under the current mode/path policySandboxedFileSystem 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:

EventSignatureMeaning
fs/write-intent(target, actor, next) => FsWriteIntentDecide 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): voidRecord 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:

ts
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 ⇒ replaceIfVersion at the observed version.
  • fs/edit-intent → unseen rejects with FS_NOT_OBSERVED; confirmed absence rejects with FS_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, and emit does 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 mutationswriteText and editText. Reads pass through untouched: every mode permits reading.

ts
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 writableRoots helper in @deepseek-ai/dsh-sandbox (packages/sandbox/sandbox/src/roots.ts): the canonicalized workspace root plus /tmp and os.tmpdir(). This is the same set the Seatbelt profile grants, so the fs fence and bash can never drift about what workspace-write promises.
  • 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:

text
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 it

For 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 approveEscalation grants a wider mode for 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 — the ObservedStateGate and its three listeners.
  • packages/fs/fs-sandbox/src/index.ts — the SandboxedFileSystem mutation fence.
  • packages/sandbox/sandbox-policy/src/index.tsSandboxPolicyService.resolve and the sandbox/mode override fold.