1
/**2
* `SandboxedFileSystem`: the sandbox-enforcing implementation of the3
* `@deepseek-ai/dsh-fs` Service Definition. It extends `LocalFileSystem` so all4
* text-storage mechanics — resolve, stat, read/stream, list, the atomic5
* write and the read-match-write edit critical section — are the local6
* implementation's, verbatim; this package adds only the per-call POLICY fence7
* on the two mutations. Reads pass through untouched: every mode permits8
* reading.9
*10
* The fence is a policy check in TRUSTED code over a MODEL-CONTROLLED path,11
* NOT a kernel boundary — the operations are the seam's own (open, rename),12
* and only the target path is untrusted, so canonicalize-then-contain is the13
* complete answer to this surface. This is containment, not a security14
* boundary; kernel-grade isolation of untrusted CODE stays `ctx.shell`'s job15
* (`@deepseek-ai/dsh-bash-sandbox`). The residual16
* TOCTOU (an ancestor symlink swapped between the containment re-check and the17
* syscall) is narrowed by re-canonicalizing immediately before delegating and18
* is accepted for this threat model.19
*20
* Per-call policy: `read-only` denies every mutation; `workspace-write` allows21
* a mutation only when the target canonicalizes under the policy's workspace22
* root or a platform temp area from the shared `writableRoots` policy;23
* `danger-full-access` delegates unfenced. A denial throws the structured24
* `FS_SANDBOX_DENIED`.25
*26
* @module @deepseek-ai/dsh-fs-sandbox27
*/29
import { Context } from '@deepseek-ai/cordis'30
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'31
import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'32
import { FsError } from '@deepseek-ai/dsh-fs'33
import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs'34
import { writableRoots } from '@deepseek-ai/dsh-sandbox'35
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'36
import type {} from '@deepseek-ai/dsh-sandbox-policy'37
import { isPathUnder } from './containment.ts'39
/**40
* Plugin config: the local backend's knobs verbatim (`cwd` resolution default41
* and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default42
* (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy`43
* resolves each calling session for every enforcing capability.44
*/45
export type Config = LocalConfig47
/**48
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it49
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole50
* swap — the model-facing tools are untouched). Its configured default mode is51
* the capability fact exposed by {@link sandboxMode}; `dsh-tool-fs` resolves52
* each session's mode and cwd into a policy for every mutation, while an53
* approved escalation may stamp a strictly wider mode for one call.54
*/55
export class SandboxedFileSystem extends LocalFileSystem {56
static inject = ['sandboxPolicy']58
private readonly defaultMode: SandboxMode59
constructor(ctx: Context, config: Config) {60
super(ctx, config)61
this.defaultMode = ctx.sandboxPolicy.defaultMode62
}64
/** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */65
override get sandboxMode(): SandboxMode {66
return this.defaultMode67
}69
/**70
* Fence the write by the per-call policy, then delegate to the inherited71
* atomic write. See {@link checkedTarget}.72
* @param target - the resolved target to write.73
* @param content - the full new file content.74
* @param expected - the write intent guarding the write; omit for unconditional.75
* @param signal - aborts before atomic publication takes effect.76
* @param sandboxPolicy - the per-call mode and workspace root; omit to use77
* the deployment fallback.78
* @returns the write outcome from the inherited backend.79
*/80
override async writeText(81
target: FsTarget,82
content: string,83
expected?: FsWriteIntent,84
signal?: AbortSignal,85
sandboxPolicy?: SandboxExecutionPolicy,86
): Promise<FsWriteOutcome> {87
return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal)88
}90
/**91
* Fence the edit by the per-call policy, then delegate to the inherited92
* atomic edit. See {@link checkedTarget}.93
* @param target - the resolved target to edit.94
* @param edit - the literal search/replace request.95
* @param expected - the version guard; omit for an unconditional edit.96
* @param signal - aborts before atomic publication takes effect.97
* @param sandboxPolicy - the per-call mode and workspace root; omit to use98
* the deployment fallback.99
* @returns the edit outcome from the inherited backend.100
*/101
override async editText(102
target: FsTarget,103
edit: FsEditRequest,104
expected?: { version: FsVersion },105
signal?: AbortSignal,106
sandboxPolicy?: SandboxExecutionPolicy,107
): Promise<FsEditOutcome> {108
return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal)109
}111
/**112
* Enforce the per-call policy against `target` and return the EXACT target the113
* mutation must use, so the checked identity is the mutated one (no114
* check-here-write-there TOCTOU). `read-only` denies; `workspace-write`115
* re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,116
* reflecting a concurrently swapped symlink), requires containment under a117
* writable root, and returns THAT fresh target; `danger-full-access` returns118
* the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on119
* refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker120
* and the escalation hint.121
*/122
private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {123
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()124
const { mode } = policy125
if (mode === 'danger-full-access') return target126
if (mode === 'read-only') {127
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')128
}129
// workspace-write: containment on the FRESH canonical path (catches a130
// symlink ancestor swapped since the tool resolved this target), and the131
// mutation delegates with THIS fresh target — never the stale one.132
const fresh = await this.resolve(target.displayPath)133
let contained = false134
for (const root of writableRoots(policy)) {135
if (await isPathUnder(fresh.targetKey, root)) {136
contained = true137
break138
}139
}140
if (!contained) {141
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')142
}143
return fresh144
}145
}147
export default SandboxedFileSystem