@stackstackstack/dsh-fs-sandbox 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +45 -0
- package/README.zh.md +45 -0
- package/lib/index.js +173 -0
- package/lib/invariant.js +20 -0
- package/lib/types/containment.d.ts +20 -0
- package/lib/types/index.d.ts +98 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/fs/fs-sandbox/README.md
|
|
5
|
+
README.md: 73df31d0f98b602c57a3e86340c3dfe0ee836cd0
|
|
6
|
+
README.zh.md: f4000b200979e951f7e10d452a11773114484185
|
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# dsh-fs-sandbox — the sandbox-enforcing filesystem backend
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
`SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading.
|
|
6
|
+
|
|
7
|
+
Its plugin config is the local backend config unchanged: `cwd` remains the relative-path resolution default, and `diffBasisMaxBytes` bounds the optional overwrite contextual-diff basis.
|
|
8
|
+
|
|
9
|
+
Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots.
|
|
10
|
+
|
|
11
|
+
## The fence
|
|
12
|
+
|
|
13
|
+
The per-call policy carries the effective mode (session override or escalation grant) together with the calling session's immutable cwd root, falling back to deployment policy only for calls without one:
|
|
14
|
+
|
|
15
|
+
- `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`.
|
|
16
|
+
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. Canonical spellings use a lexical fast path; an identity-based ancestor fallback recognizes alias-equivalent roots such as Windows long names and 8.3 names without treating unrelated prefixes as contained. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
|
|
17
|
+
- `danger-full-access` — delegates unfenced.
|
|
18
|
+
|
|
19
|
+
## Threat model: a policy fence, not a kernel boundary
|
|
20
|
+
|
|
21
|
+
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. This mirrors the `code-runtime` stance: containment, not a security boundary. Kernel-grade isolation of untrusted CODE stays `ctx.shell`'s job ([`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md)). 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.
|
|
22
|
+
|
|
23
|
+
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
|
|
24
|
+
|
|
25
|
+
## Model Experience
|
|
26
|
+
|
|
27
|
+
### Filesystem policy and refusals
|
|
28
|
+
|
|
29
|
+
#### What the model sees
|
|
30
|
+
|
|
31
|
+
The policy owner contributes capability-neutral `sandbox:policy` context. Indirectly, `dsh-tool-fs` renders this backend's `FS_SANDBOX_DENIED` refusals as the `[sandbox: file access denied under <mode> mode]` marker plus the same-turn escalation hint.
|
|
32
|
+
|
|
33
|
+
#### Token effect
|
|
34
|
+
|
|
35
|
+
The current-policy clause adds a small runtime-context message while this backend is mounted; a denial adds the bounded marker and escalation hint to conversation history.
|
|
36
|
+
|
|
37
|
+
#### KV Cache effect
|
|
38
|
+
|
|
39
|
+
A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; operation results remain append-only.
|
|
40
|
+
|
|
41
|
+
## Known Limitations and Deferred Work
|
|
42
|
+
|
|
43
|
+
- **A policy fence, not a kernel boundary** — the check is trusted code over a model-controlled path, so the residual resolve-to-syscall TOCTOU is narrowed (by the in-place re-canonicalization) but not eliminated; adversarial host processes are out of scope. Kernel-grade isolation of untrusted code stays `ctx.shell`'s.
|
|
44
|
+
- **Fence-vs-runner parity is derived from one owner** — the writable set comes from `writableRoots`, shared with the Seatbelt profile; a runner profile that defines its writable set elsewhere would drift.
|
|
45
|
+
- **Requires `ctx.sandboxPolicy`** — tools use it to resolve each session policy and the backend uses it for agentless-call fallbacks; the backend does not confine without it composed.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# dsh-fs-sandbox:强制沙箱的文件系统后端
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
`SandboxedFileSystem` 扩展 [`LocalFileSystem`](../fs-local/README.md) 并注册为 `ctx.fs`。它逐字继承全部文本存储机制(解析、stat、读取/流式读取、列出、原子写入、按读取、匹配、写入顺序执行的编辑临界区),只为 `writeText`/`editText` 增加按调用的模式围栏。读取始终直接通过:所有模式都允许读取。
|
|
6
|
+
|
|
7
|
+
它原样复用本地后端配置:`cwd` 仍是相对路径的解析默认值,`diffBasisMaxBytes` 则限制可选的覆写上下文 diff 基础。
|
|
8
|
+
|
|
9
|
+
只需加载它来替代 `dsh-fs-local`,并同时加载 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md),即可完成替换;面向模型的工具(`dsh-tool-fs`)无需改动。工具层把调用会话的模式和 cwd 解析为与 bash 相同的按调用策略,因此两个能力族绝不会约束到不同根目录。
|
|
10
|
+
|
|
11
|
+
## 围栏
|
|
12
|
+
|
|
13
|
+
按调用策略携带有效模式(会话覆盖值或升级授权)和调用会话不可变的 cwd 根目录;只有没有会话的调用才回退到部署策略:
|
|
14
|
+
|
|
15
|
+
- `read-only`:以结构化 `FS_SANDBOX_DENIED` 拒绝所有变更;
|
|
16
|
+
- `workspace-write`:只有目标规范化后位于可写根目录下,才允许变更。可写根包括工作区根目录和平台临时区域(`/tmp`、`os.tmpdir()`),与 Seatbelt profile 授权的集合相同;该集合由唯一的 [`writableRoots`](../../sandbox/README.md) 函数派生,使 fs 围栏与 bash runner 不会漂移。规范拼写使用词法快速路径;基于身份的祖先回退可以识别 Windows 长名称和 8.3 名称等别名等价根目录,而不会把无关前缀视为包含关系。委托前会立即重新规范化目标,因此工具解析后被替换的祖先符号链接也会被发现;
|
|
17
|
+
- `danger-full-access`:不加围栏直接委托。
|
|
18
|
+
|
|
19
|
+
## 威胁模型:策略围栏,而非内核边界
|
|
20
|
+
|
|
21
|
+
围栏是在可信代码中检查模型控制的路径。操作本身属于 seam(open、rename),只有目标路径不可信,因此「规范化后检查包含关系」就是该接口的完整答案。这与 `code-runtime` 的立场相同:提供约束,但不是安全边界。不可信代码的内核级隔离仍由 `ctx.shell` 负责([`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md))。剩余 TOCTOU(在包含关系复查与系统调用之间替换祖先符号链接)会通过写入前立即重新规范化来缩小,并为该威胁模型所接受;内核严密边界需要 `openat2` 一类原语,其可移植性成本在此不值得。
|
|
22
|
+
|
|
23
|
+
拒绝是结构化 `FsError`(`FS_SANDBOX_DENIED`,携带有效模式),不通过 stderr 文本推断(不同于 bash 的内核拒绝),因为进程内围栏准确知道自己拒绝了什么。面向模型的 `[sandbox: file access denied under <mode> mode]` 标记以及唯一一次获批的更宽权限重试位于工具层(`dsh-tool-fs`),与 bash 完全相同。见[跨能力族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)。
|
|
24
|
+
|
|
25
|
+
## 模型体验
|
|
26
|
+
|
|
27
|
+
### 文件系统策略与拒绝
|
|
28
|
+
|
|
29
|
+
#### 模型看到的内容
|
|
30
|
+
|
|
31
|
+
策略归属方会贡献与具体能力无关的 `sandbox:policy` 上下文。作为间接影响,`dsh-tool-fs` 会把本后端的 `FS_SANDBOX_DENIED` 拒绝渲染为 `[sandbox: file access denied under <mode> mode]` 标记和同轮次升级提示。
|
|
32
|
+
|
|
33
|
+
#### Token 影响
|
|
34
|
+
|
|
35
|
+
该后端挂载期间,当前策略条款会增加一条简短的运行时上下文消息;拒绝则会把有界标记和升级提示追加到对话历史。
|
|
36
|
+
|
|
37
|
+
#### KV Cache 影响
|
|
38
|
+
|
|
39
|
+
常驻策略发生变化时,会在保留的历史之后追加一份由归属方渲染、取代先前状态的运行时上下文快照;操作结果保持仅追加。
|
|
40
|
+
|
|
41
|
+
## 已知限制与暂缓事项
|
|
42
|
+
|
|
43
|
+
- **策略围栏,而非内核边界**:该检查是可信代码处理模型控制的路径,因此解析到系统调用之间残留的 TOCTOU 会被原位重新规范化缩小,但不会消除;对抗性宿主进程不在范围内。不可信代码的内核级隔离仍属于 `ctx.shell`。
|
|
44
|
+
- **围栏与 runner 的一致性由单一所有方派生**:可写集合来自 `writableRoots`,该函数与 Seatbelt profile 共享;在其他位置定义可写集合的 runner profile 会发生漂移。
|
|
45
|
+
- **要求 `ctx.sandboxPolicy`**:工具使用它解析每个会话策略,后端用它处理无 agent(智能体)调用的回退;未组合该服务时,后端不会实施约束。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { LocalFileSystem } from "@stackstackstack/dsh-fs-local";
|
|
2
|
+
import { FsError } from "@stackstackstack/dsh-fs";
|
|
3
|
+
import { writableRoots } from "@stackstackstack/dsh-sandbox";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
import { dirname, sep } from "node:path";
|
|
6
|
+
//#region lib/types/containment.js
|
|
7
|
+
/**
|
|
8
|
+
* Path-containment mechanics for the filesystem sandbox. Canonical spellings
|
|
9
|
+
* take the fast lexical path; filesystem identity supplies the conservative
|
|
10
|
+
* fallback for alias-equivalent roots such as Windows 8.3 names and casing.
|
|
11
|
+
* @module @stackstackstack/dsh-fs-sandbox/containment
|
|
12
|
+
*/
|
|
13
|
+
const MISSING_CODES = new Set(["ENOENT", "ENOTDIR"]);
|
|
14
|
+
function isMissing(error) {
|
|
15
|
+
const code = error.code;
|
|
16
|
+
return MISSING_CODES.has(code);
|
|
17
|
+
}
|
|
18
|
+
function comparablePath(path, caseSensitive) {
|
|
19
|
+
return caseSensitive ? path : path.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
function isLexicallyUnder(path, root, caseSensitive) {
|
|
22
|
+
const comparableTarget = comparablePath(path, caseSensitive);
|
|
23
|
+
const comparableRoot = comparablePath(root, caseSensitive);
|
|
24
|
+
if (comparableTarget === comparableRoot) return true;
|
|
25
|
+
const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
|
|
26
|
+
return comparableTarget.startsWith(prefix);
|
|
27
|
+
}
|
|
28
|
+
async function statIfPresent(path) {
|
|
29
|
+
try {
|
|
30
|
+
return await stat(path, { bigint: true });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
/* v8 ignore else -- a non-missing stat failure requires a host permission or I/O fault after resolve reached this ancestor. */
|
|
33
|
+
if (isMissing(error)) return void 0;
|
|
34
|
+
/* v8 ignore next -- requires a host permission or I/O fault after resolve already reached this ancestor. */
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function sameIdentity(left, right) {
|
|
39
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Determine whether a canonical target is a writable root or lies beneath it.
|
|
43
|
+
* The lexical fast path handles normal canonical spellings. When spellings
|
|
44
|
+
* differ, walk the target's existing ancestors and compare filesystem identity
|
|
45
|
+
* with the root; this recognizes Windows long-name/8.3 aliases and casing
|
|
46
|
+
* without weakening containment to a textual approximation.
|
|
47
|
+
* @param path - canonical target key, which may end in a missing suffix.
|
|
48
|
+
* @param root - canonical writable root.
|
|
49
|
+
* @param caseSensitive - whether lexical comparison preserves case; defaults
|
|
50
|
+
* to the host filesystem convention used by supported platforms.
|
|
51
|
+
* @returns whether the target is the root or a descendant of it.
|
|
52
|
+
*/
|
|
53
|
+
async function isPathUnder(path, root, caseSensitive = process.platform !== "win32") {
|
|
54
|
+
if (isLexicallyUnder(path, root, caseSensitive)) return true;
|
|
55
|
+
const rootInfo = await statIfPresent(root);
|
|
56
|
+
if (!rootInfo) return false;
|
|
57
|
+
let ancestor = path;
|
|
58
|
+
while (true) {
|
|
59
|
+
const ancestorInfo = await statIfPresent(ancestor);
|
|
60
|
+
if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true;
|
|
61
|
+
const parent = dirname(ancestor);
|
|
62
|
+
if (parent === ancestor) return false;
|
|
63
|
+
ancestor = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region lib/types/index.js
|
|
68
|
+
/**
|
|
69
|
+
* `SandboxedFileSystem`: the sandbox-enforcing implementation of the
|
|
70
|
+
* `@stackstackstack/dsh-fs` Service Definition. It extends `LocalFileSystem` so all
|
|
71
|
+
* text-storage mechanics — resolve, stat, read/stream, list, the atomic
|
|
72
|
+
* write and the read-match-write edit critical section — are the local
|
|
73
|
+
* implementation's, verbatim; this package adds only the per-call POLICY fence
|
|
74
|
+
* on the two mutations. Reads pass through untouched: every mode permits
|
|
75
|
+
* reading.
|
|
76
|
+
*
|
|
77
|
+
* The fence is a policy check in TRUSTED code over a MODEL-CONTROLLED path,
|
|
78
|
+
* NOT a kernel boundary — the operations are the seam's own (open, rename),
|
|
79
|
+
* and only the target path is untrusted, so canonicalize-then-contain is the
|
|
80
|
+
* complete answer to this surface. Kernel-grade isolation of untrusted CODE
|
|
81
|
+
* stays `ctx.shell`'s job (`@stackstackstack/dsh-bash-sandbox`). This mirrors the
|
|
82
|
+
* `code-runtime` stance: containment, not a security boundary. The residual
|
|
83
|
+
* TOCTOU (an ancestor symlink swapped between the containment re-check and the
|
|
84
|
+
* syscall) is narrowed by re-canonicalizing immediately before delegating and
|
|
85
|
+
* is accepted for this threat model.
|
|
86
|
+
*
|
|
87
|
+
* Per-call policy: `read-only` denies every mutation; `workspace-write` allows
|
|
88
|
+
* a mutation only when the target canonicalizes under the policy's workspace
|
|
89
|
+
* root or a platform temp area (the SAME writable-root set Seatbelt grants,
|
|
90
|
+
* derived from the one `writableRoots` function so bash and fs cannot drift);
|
|
91
|
+
* `danger-full-access` delegates unfenced. A denial throws the structured
|
|
92
|
+
* `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel
|
|
93
|
+
* stderr), because an in-process fence knows exactly what it refused. The
|
|
94
|
+
* escalation retry lives in the tool layer (`@stackstackstack/dsh-tool-fs`),
|
|
95
|
+
* exactly as bash's does.
|
|
96
|
+
*
|
|
97
|
+
* @module @stackstackstack/dsh-fs-sandbox
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
|
|
101
|
+
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
|
|
102
|
+
* swap — the model-facing tools are untouched). Its configured default mode is
|
|
103
|
+
* the capability fact exposed by {@link sandboxMode}; `dsh-tool-fs` resolves
|
|
104
|
+
* each session's mode and cwd into a policy for every mutation, while an
|
|
105
|
+
* approved escalation may stamp a strictly wider mode for one call.
|
|
106
|
+
*/
|
|
107
|
+
var SandboxedFileSystem = class extends LocalFileSystem {
|
|
108
|
+
static inject = ["sandboxPolicy"];
|
|
109
|
+
defaultMode;
|
|
110
|
+
constructor(ctx, config) {
|
|
111
|
+
super(ctx, config);
|
|
112
|
+
this.defaultMode = ctx.sandboxPolicy.defaultMode;
|
|
113
|
+
}
|
|
114
|
+
/** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */
|
|
115
|
+
get sandboxMode() {
|
|
116
|
+
return this.defaultMode;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Fence the write by the per-call policy, then delegate to the inherited
|
|
120
|
+
* atomic write. See {@link checkedTarget}.
|
|
121
|
+
* @param target - the resolved target to write.
|
|
122
|
+
* @param content - the full new file content.
|
|
123
|
+
* @param expected - the write intent guarding the write; omit for unconditional.
|
|
124
|
+
* @param signal - aborts before atomic publication takes effect.
|
|
125
|
+
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
|
|
126
|
+
* the deployment fallback.
|
|
127
|
+
* @returns the write outcome from the inherited backend.
|
|
128
|
+
*/
|
|
129
|
+
async writeText(target, content, expected, signal, sandboxPolicy) {
|
|
130
|
+
return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Fence the edit by the per-call policy, then delegate to the inherited
|
|
134
|
+
* atomic edit. See {@link checkedTarget}.
|
|
135
|
+
* @param target - the resolved target to edit.
|
|
136
|
+
* @param edit - the literal search/replace request.
|
|
137
|
+
* @param expected - the version guard; omit for an unconditional edit.
|
|
138
|
+
* @param signal - aborts before atomic publication takes effect.
|
|
139
|
+
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
|
|
140
|
+
* the deployment fallback.
|
|
141
|
+
* @returns the edit outcome from the inherited backend.
|
|
142
|
+
*/
|
|
143
|
+
async editText(target, edit, expected, signal, sandboxPolicy) {
|
|
144
|
+
return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Enforce the per-call policy against `target` and return the EXACT target the
|
|
148
|
+
* mutation must use, so the checked identity is the mutated one (no
|
|
149
|
+
* check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
|
|
150
|
+
* re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,
|
|
151
|
+
* reflecting a concurrently swapped symlink), requires containment under a
|
|
152
|
+
* writable root, and returns THAT fresh target; `danger-full-access` returns
|
|
153
|
+
* the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on
|
|
154
|
+
* refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker
|
|
155
|
+
* and the escalation hint.
|
|
156
|
+
*/
|
|
157
|
+
async checkedTarget(target, sandboxPolicy) {
|
|
158
|
+
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
|
|
159
|
+
const { mode } = policy;
|
|
160
|
+
if (mode === "danger-full-access") return target;
|
|
161
|
+
if (mode === "read-only") throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
|
|
162
|
+
const fresh = await this.resolve(target.displayPath);
|
|
163
|
+
let contained = false;
|
|
164
|
+
for (const root of writableRoots(policy)) if (await isPathUnder(fresh.targetKey, root)) {
|
|
165
|
+
contained = true;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (!contained) throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
|
|
169
|
+
return fresh;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
//#endregion
|
|
173
|
+
export { SandboxedFileSystem, SandboxedFileSystem as default };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@stackstackstack/dsh-fs-sandbox`.
|
|
4
|
+
* @module @stackstackstack/dsh-fs-sandbox/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@stackstackstack/dsh-fs-sandbox";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "fs-sandbox-invariant";
|
|
9
|
+
/** Services required before the companion can register. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/** No runtime invariant: this stateless adapter delegates policy and filesystem relations to their owning seams. */
|
|
12
|
+
const install = () => {};
|
|
13
|
+
/**
|
|
14
|
+
* Register this package's invariant companion.
|
|
15
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
16
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
17
|
+
*/
|
|
18
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
19
|
+
//#endregion
|
|
20
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-containment mechanics for the filesystem sandbox. Canonical spellings
|
|
3
|
+
* take the fast lexical path; filesystem identity supplies the conservative
|
|
4
|
+
* fallback for alias-equivalent roots such as Windows 8.3 names and casing.
|
|
5
|
+
* @module @stackstackstack/dsh-fs-sandbox/containment
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Determine whether a canonical target is a writable root or lies beneath it.
|
|
9
|
+
* The lexical fast path handles normal canonical spellings. When spellings
|
|
10
|
+
* differ, walk the target's existing ancestors and compare filesystem identity
|
|
11
|
+
* with the root; this recognizes Windows long-name/8.3 aliases and casing
|
|
12
|
+
* without weakening containment to a textual approximation.
|
|
13
|
+
* @param path - canonical target key, which may end in a missing suffix.
|
|
14
|
+
* @param root - canonical writable root.
|
|
15
|
+
* @param caseSensitive - whether lexical comparison preserves case; defaults
|
|
16
|
+
* to the host filesystem convention used by supported platforms.
|
|
17
|
+
* @returns whether the target is the root or a descendant of it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isPathUnder(path: string, root: string, caseSensitive?: boolean): Promise<boolean>;
|
|
20
|
+
//# sourceMappingURL=containment.d.ts.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SandboxedFileSystem`: the sandbox-enforcing implementation of the
|
|
3
|
+
* `@stackstackstack/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. Kernel-grade isolation of untrusted CODE
|
|
14
|
+
* stays `ctx.shell`'s job (`@stackstackstack/dsh-bash-sandbox`). This mirrors the
|
|
15
|
+
* `code-runtime` stance: containment, not a security boundary. 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 (the SAME writable-root set Seatbelt grants,
|
|
23
|
+
* derived from the one `writableRoots` function so bash and fs cannot drift);
|
|
24
|
+
* `danger-full-access` delegates unfenced. A denial throws the structured
|
|
25
|
+
* `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel
|
|
26
|
+
* stderr), because an in-process fence knows exactly what it refused. The
|
|
27
|
+
* escalation retry lives in the tool layer (`@stackstackstack/dsh-tool-fs`),
|
|
28
|
+
* exactly as bash's does.
|
|
29
|
+
*
|
|
30
|
+
* @module @stackstackstack/dsh-fs-sandbox
|
|
31
|
+
*/
|
|
32
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
33
|
+
import { LocalFileSystem } from '@stackstackstack/dsh-fs-local';
|
|
34
|
+
import type { Config as LocalConfig } from '@stackstackstack/dsh-fs-local';
|
|
35
|
+
import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@stackstackstack/dsh-fs';
|
|
36
|
+
import type { SandboxExecutionPolicy, SandboxMode } from '@stackstackstack/dsh-sandbox';
|
|
37
|
+
/**
|
|
38
|
+
* Plugin config: the local backend's knobs verbatim (`cwd` resolution default
|
|
39
|
+
* and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default
|
|
40
|
+
* (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy`
|
|
41
|
+
* resolves each calling session for every enforcing capability.
|
|
42
|
+
*/
|
|
43
|
+
export type Config = LocalConfig;
|
|
44
|
+
/**
|
|
45
|
+
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
|
|
46
|
+
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
|
|
47
|
+
* swap — the model-facing tools are untouched). Its configured default mode is
|
|
48
|
+
* the capability fact exposed by {@link sandboxMode}; `dsh-tool-fs` resolves
|
|
49
|
+
* each session's mode and cwd into a policy for every mutation, while an
|
|
50
|
+
* approved escalation may stamp a strictly wider mode for one call.
|
|
51
|
+
*/
|
|
52
|
+
export declare class SandboxedFileSystem extends LocalFileSystem {
|
|
53
|
+
static inject: string[];
|
|
54
|
+
private readonly defaultMode;
|
|
55
|
+
constructor(ctx: Context, config: Config);
|
|
56
|
+
/** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */
|
|
57
|
+
get sandboxMode(): SandboxMode;
|
|
58
|
+
/**
|
|
59
|
+
* Fence the write by the per-call policy, then delegate to the inherited
|
|
60
|
+
* atomic write. See {@link checkedTarget}.
|
|
61
|
+
* @param target - the resolved target to write.
|
|
62
|
+
* @param content - the full new file content.
|
|
63
|
+
* @param expected - the write intent guarding the write; omit for unconditional.
|
|
64
|
+
* @param signal - aborts before atomic publication takes effect.
|
|
65
|
+
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
|
|
66
|
+
* the deployment fallback.
|
|
67
|
+
* @returns the write outcome from the inherited backend.
|
|
68
|
+
*/
|
|
69
|
+
writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsWriteOutcome>;
|
|
70
|
+
/**
|
|
71
|
+
* Fence the edit by the per-call policy, then delegate to the inherited
|
|
72
|
+
* atomic edit. See {@link checkedTarget}.
|
|
73
|
+
* @param target - the resolved target to edit.
|
|
74
|
+
* @param edit - the literal search/replace request.
|
|
75
|
+
* @param expected - the version guard; omit for an unconditional edit.
|
|
76
|
+
* @param signal - aborts before atomic publication takes effect.
|
|
77
|
+
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
|
|
78
|
+
* the deployment fallback.
|
|
79
|
+
* @returns the edit outcome from the inherited backend.
|
|
80
|
+
*/
|
|
81
|
+
editText(target: FsTarget, edit: FsEditRequest, expected?: {
|
|
82
|
+
version: FsVersion;
|
|
83
|
+
}, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsEditOutcome>;
|
|
84
|
+
/**
|
|
85
|
+
* Enforce the per-call policy against `target` and return the EXACT target the
|
|
86
|
+
* mutation must use, so the checked identity is the mutated one (no
|
|
87
|
+
* check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
|
|
88
|
+
* re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,
|
|
89
|
+
* reflecting a concurrently swapped symlink), requires containment under a
|
|
90
|
+
* writable root, and returns THAT fresh target; `danger-full-access` returns
|
|
91
|
+
* the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on
|
|
92
|
+
* refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker
|
|
93
|
+
* and the escalation hint.
|
|
94
|
+
*/
|
|
95
|
+
private checkedTarget;
|
|
96
|
+
}
|
|
97
|
+
export default SandboxedFileSystem;
|
|
98
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@stackstackstack/dsh-fs-sandbox`.
|
|
3
|
+
* @module @stackstackstack/dsh-fs-sandbox/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "fs-sandbox-invariant";
|
|
8
|
+
/** Services required before the companion can register. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stackstackstack/dsh-fs-sandbox",
|
|
3
|
+
"description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through",
|
|
4
|
+
"version": "0.1.5",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/fs/fs-sandbox"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@stackstackstack/dsh-fs": "^0.1.5",
|
|
36
|
+
"@stackstackstack/dsh-fs-local": "^0.1.5",
|
|
37
|
+
"@stackstackstack/dsh-invariants": "^0.1.5",
|
|
38
|
+
"@stackstackstack/dsh-sandbox": "^0.1.5",
|
|
39
|
+
"@stackstackstack/dsh-sandbox-policy": "^0.1.5",
|
|
40
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@stackstackstack/dsh-fs-local": "^0.1.5",
|
|
44
|
+
"@stackstackstack/dsh-fs": "^0.1.5",
|
|
45
|
+
"@stackstackstack/dsh-sandbox-policy": "^0.1.5",
|
|
46
|
+
"@stackstackstack/dsh-invariants": "^0.1.5",
|
|
47
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
48
|
+
"@stackstackstack/dsh-sandbox": "^0.1.5"
|
|
49
|
+
}
|
|
50
|
+
}
|