返回源码地图

packages/fs/fs-sandbox/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 10;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * `SandboxedFileSystem`: the sandbox-enforcing implementation of the
3 * `@deepseek-ai/dsh-fs` Service Definition. It extends `LocalFileSystem` so all
4 * text-storage mechanics — resolve, stat, read/stream, list, the atomic
5 * write and the read-match-write edit critical section — are the local
6 * implementation's, verbatim; this package adds only the per-call POLICY fence
7 * on the two mutations. Reads pass through untouched: every mode permits
8 * 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 the
13 * complete answer to this surface. This is containment, not a security
14 * boundary; kernel-grade isolation of untrusted CODE stays `ctx.shell`'s job
15 * (`@deepseek-ai/dsh-bash-sandbox`). The residual
16 * TOCTOU (an ancestor symlink swapped between the containment re-check and the
17 * syscall) is narrowed by re-canonicalizing immediately before delegating and
18 * is accepted for this threat model.
19 *
20 * Per-call policy: `read-only` denies every mutation; `workspace-write` allows
21 * a mutation only when the target canonicalizes under the policy's workspace
22 * root or a platform temp area from the shared `writableRoots` policy;
23 * `danger-full-access` delegates unfenced. A denial throws the structured
24 * `FS_SANDBOX_DENIED`.
25 *
26 * @module @deepseek-ai/dsh-fs-sandbox
27 */
28
29import { Context } from '@deepseek-ai/cordis'
30import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
31import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'
32import { FsError } from '@deepseek-ai/dsh-fs'
33import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
34import { writableRoots } from '@deepseek-ai/dsh-sandbox'
35import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
36import type {} from '@deepseek-ai/dsh-sandbox-policy'
37import { isPathUnder } from './containment.ts'
38
39/**
40 * Plugin config: the local backend's knobs verbatim (`cwd` resolution default
41 * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default
42 * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy`
43 * resolves each calling session for every enforcing capability.
44 */
45export type Config = LocalConfig
46
47/**
48 * Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
49 * INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
50 * swap — the model-facing tools are untouched). Its configured default mode is
51 * the capability fact exposed by {@link sandboxMode}; `dsh-tool-fs` resolves
52 * each session's mode and cwd into a policy for every mutation, while an
53 * approved escalation may stamp a strictly wider mode for one call.
54 */
55export class SandboxedFileSystem extends LocalFileSystem {
56 static inject = ['sandboxPolicy']
57
58 private readonly defaultMode: SandboxMode
59 constructor(ctx: Context, config: Config) {
60 super(ctx, config)
61 this.defaultMode = ctx.sandboxPolicy.defaultMode
62 }
63
64 /** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */
65 override get sandboxMode(): SandboxMode {
66 return this.defaultMode
67 }
68
69 /**
70 * Fence the write by the per-call policy, then delegate to the inherited
71 * 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 use
77 * 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 }
89
90 /**
91 * Fence the edit by the per-call policy, then delegate to the inherited
92 * 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 use
98 * 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 }
110
111 /**
112 * Enforce the per-call policy against `target` and return the EXACT target the
113 * mutation must use, so the checked identity is the mutated one (no
114 * 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 a
117 * writable root, and returns THAT fresh target; `danger-full-access` returns
118 * the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on
119 * refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker
120 * 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 } = policy
125 if (mode === 'danger-full-access') return target
126 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 a
130 // symlink ancestor swapped since the tool resolved this target), and the
131 // mutation delegates with THIS fresh target — never the stale one.
132 const fresh = await this.resolve(target.displayPath)
133 let contained = false
134 for (const root of writableRoots(policy)) {
135 if (await isPathUnder(fresh.targetKey, root)) {
136 contained = true
137 break
138 }
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 fresh
144 }
145}
146
147export default SandboxedFileSystem