dsh-remote 0.8.72 → 0.8.73

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.
@@ -1,183 +1,183 @@
1
- // dsh-remote — remote sandbox semantics (issue: parity with local sandbox).
2
- //
3
- // The gap this closes: a local agent session runs under `ctx.sandboxPolicy`
4
- // (read-only / workspace-write / danger-full-access), but every rw_* tool ran
5
- // remote commands with NO policy at all. A confined session could therefore
6
- // reach a remote host and mutate anything — the sandbox was not bypassed by a
7
- // flaw in its enforcement, it simply had no say over a different machine.
8
- //
9
- // Why this is a TOOL-LAYER fence rather than `ctx.sandbox.confine()`: the core
10
- // sandbox service wraps argv for a LOCAL spawn (`confine(argv, policy)` returns
11
- // argv to spawn on THIS host). A remote host has no sandbox runner, so wrapping
12
- // local argv cannot constrain a process on the other end of an SSH channel. The
13
- // core's own docs say exactly this — "Containers, microVMs, and remote
14
- // execution replace the surrounding capability seam instead". So the honest
15
- // enforcement point is here: decide, per operation, whether this session's mode
16
- // permits that remote path/command, and refuse with the OFFICIAL denial shape
17
- // so the model's escalation affordances keep working.
18
- //
19
- // Threat model (stated plainly, matching the official dsh-fs-sandbox header):
20
- // this is CONTAINMENT, not a security boundary. Paths are compared lexically, so
21
- // symlinks and TOCTOU races inside the remote host can escape; a remote command
22
- // can `cd` anywhere once bash runs with the user's own privileges. What it does
23
- // buy, and what the issue asked for: a confined session can no longer mutate a
24
- // remote tree outside its workspace BY ACCIDENT through the file tools, and
25
- // `rw_exec` refuses outright under read-only instead of silently running
26
- // anything. Deployments needing a hard boundary must confine on the REMOTE side
27
- // (containers, per-user accounts, filesystem ACLs).
28
-
29
- import path from 'node:path'
30
- import { normalizeRemotePath, relPathUnder } from './paths.js'
31
-
32
- /** The three file-effect modes, mirroring `@deepseek-ai/dsh-sandbox`. */
33
- export const REMOTE_SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
34
-
35
- /** Denial marker prefix, matching the official `sandboxDenialMarker` shape. */
36
- const DENIAL_PREFIX = '[sandbox: '
37
-
38
- /**
39
- * Whether `target` lies inside `root` using lexical, separator-aware
40
- * comparison. Mirrors the official `isPathInsideWorkspace`: both sides are
41
- * normalized so `..` cannot escape, and `/tmp/foo` does not contain
42
- * `/tmp/foobar`.
43
- *
44
- * A root of `/` contains everything (the whole filesystem is the workspace),
45
- * which is what makes "workspace = filesystem root" a legal, permissive setup
46
- * (issue #60) rather than a denial of everything.
47
- *
48
- * @param {string} target - the remote path being touched.
49
- * @param {string} root - the remote workspace root.
50
- * @returns {boolean}
51
- */
52
- export function isInsideRemote(target, root) {
53
- if (typeof target !== 'string' || typeof root !== 'string') return false
54
- // POSIX-absolute only. `normalizeRemotePath` deliberately coerces a relative
55
- // input to an absolute one (`tmp/foo` → `/tmp/foo`, `../foo` → `/foo`), which
56
- // is right for a user-supplied path but WRONG for a fence: it would turn an
57
- // unprovable root into a confident one. A root that is not absolute proves
58
- // nothing, so refuse before normalizing.
59
- if (!isPosixAbsolute(root)) return false
60
- const t = normalizeRemotePath(target)
61
- let r = normalizeRemotePath(root)
62
- if (r === '/' || r === '') return true
63
- r = r.replace(/\/+$/, '')
64
- if (t === r) return true
65
- return t.startsWith(r + '/')
66
- }
67
-
68
- /**
69
- * Whether a path is an absolute POSIX or Windows-Drive path, WITHOUT the
70
- * coercing normalization `normalizeRemotePath` performs.
71
- *
72
- * @param {string} p
73
- * @returns {boolean}
74
- */
75
- export function isPosixAbsolute(p) {
76
- const s = String(p || '')
77
- if (!s) return false
78
- if (s.startsWith('/')) return true // POSIX
79
- if (/^[a-zA-Z]:[\\/]/.test(s)) return true // Windows drive
80
- if (/^\\\\[^\\]+/.test(s)) return true // Windows UNC
81
- return false
82
- }
83
-
84
- /**
85
- * Decide whether one remote operation is permitted under a mode.
86
- *
87
- * `operation` is the coarse kind the tools act with:
88
- * - `read` — reading remote data (never denied: read-only still reads)
89
- * - `write` — creating/replacing/removing a REMOTE PATH (path fence)
90
- * - `exec` — running an arbitrary remote command
91
- * - `tunnel` — opening a port forward
92
- * - `connect`— dialing/authenticating a machine (never denied: without it
93
- * nothing else could work, and it changes no remote state)
94
- *
95
- * @param {object} arg
96
- * @param {string} arg.mode - the resolved sandbox mode.
97
- * @param {string} arg.operation - see above.
98
- * @param {string} [arg.path] - the remote path a `write` targets.
99
- * @param {string} arg.workspaceRoot - the remote workspace root.
100
- * @returns {{allowed: boolean, reason?: string, mode: string, subject?: string}}
101
- */
102
- export function checkRemoteSandbox({ mode, operation, path: target, workspaceRoot }) {
103
- const m = REMOTE_SANDBOX_MODES.includes(mode) ? mode : 'read-only'
104
- // Fail closed on an unknown mode, exactly like the official renderPolicyContext.
105
- const effective = m
106
-
107
- if (operation === 'read' || operation === 'connect') return { allowed: true, mode: effective }
108
-
109
- if (effective === 'danger-full-access') return { allowed: true, mode: effective }
110
-
111
- if (effective === 'read-only') {
112
- // read-only refuses every state-changing remote operation. This is the
113
- // strict reading of the official mode ("`read-only` permits only required
114
- // sinks such as /dev/null") and the one that keeps a confined session
115
- // honest on a machine whose filesystem it cannot police.
116
- return {
117
- allowed: false,
118
- mode: effective,
119
- subject: operation === 'exec' ? 'command' : operation === 'tunnel' ? 'tunnel' : 'operation',
120
- reason:
121
- operation === 'exec'
122
- ? 'read-only sessions may not run commands on a remote host'
123
- : operation === 'tunnel'
124
- ? 'read-only sessions may not open remote port forwards'
125
- : 'read-only sessions may not modify a remote host',
126
- }
127
- }
128
-
129
- // workspace-write: writes must stay inside the workspace; exec and tunnel are
130
- // allowed because a command's effects cannot be bounded lexically (and the
131
- // official bash tool under workspace-write DOES run commands, confining them
132
- // via the OS). Refusing exec here would make workspace-write useless for the
133
- // primary remote use case (builds, tests), while pretending to confine it
134
- // would be worse than saying we do not.
135
- if (operation === 'exec' || operation === 'tunnel') return { allowed: true, mode: effective }
136
-
137
- // A write with no resolvable workspace root cannot be proven inside it.
138
- if (!workspaceRoot) {
139
- return {
140
- allowed: false,
141
- mode: effective,
142
- subject: 'operation',
143
- reason: 'workspace-write needs a remote workspace root to fence writes; none is set',
144
- }
145
- }
146
- if (isInsideRemote(target, workspaceRoot)) return { allowed: true, mode: effective }
147
- return {
148
- allowed: false,
149
- mode: effective,
150
- subject: 'operation',
151
- reason: `workspace-write only permits changes inside ${workspaceRoot}`,
152
- }
153
- }
154
-
155
- /**
156
- * Render a denial into the official error shape so the model sees the same
157
- * thing it would from a local sandbox denial (and can use the same escalation
158
- * affordances when the composition advertises them).
159
- *
160
- * @param {{mode: string, reason?: string, subject?: string}} denial
161
- * @returns {Error}
162
- */
163
- export function remoteSandboxDenial(denial) {
164
- const mode = (denial && denial.mode) || 'read-only'
165
- const subject = (denial && denial.subject) || 'operation'
166
- const hint = `[escalation: raise sandbox mode to retry the ${subject} once]`
167
- const err = new Error(`${DENIAL_PREFIX}${mode}]\n${denial && denial.reason ? denial.reason + '\n' : ''}${hint}`)
168
- err.code = 'FS_SANDBOX_DENIED'
169
- err.remoteSandbox = { mode, subject }
170
- return err
171
- }
172
-
173
- /**
174
- * Relative path of a remote target under the workspace root, for diagnostics.
175
- * Returns null when the target is outside it.
176
- * @param {string} target
177
- * @param {string} root
178
- * @returns {string|null}
179
- */
180
- export function remoteRelUnder(target, root) {
181
- const rel = relPathUnder(root, target)
182
- return rel === null ? null : rel
183
- }
1
+ // dsh-remote — remote sandbox semantics (issue: parity with local sandbox).
2
+ //
3
+ // The gap this closes: a local agent session runs under `ctx.sandboxPolicy`
4
+ // (read-only / workspace-write / danger-full-access), but every rw_* tool ran
5
+ // remote commands with NO policy at all. A confined session could therefore
6
+ // reach a remote host and mutate anything — the sandbox was not bypassed by a
7
+ // flaw in its enforcement, it simply had no say over a different machine.
8
+ //
9
+ // Why this is a TOOL-LAYER fence rather than `ctx.sandbox.confine()`: the core
10
+ // sandbox service wraps argv for a LOCAL spawn (`confine(argv, policy)` returns
11
+ // argv to spawn on THIS host). A remote host has no sandbox runner, so wrapping
12
+ // local argv cannot constrain a process on the other end of an SSH channel. The
13
+ // core's own docs say exactly this — "Containers, microVMs, and remote
14
+ // execution replace the surrounding capability seam instead". So the honest
15
+ // enforcement point is here: decide, per operation, whether this session's mode
16
+ // permits that remote path/command, and refuse with the OFFICIAL denial shape
17
+ // so the model's escalation affordances keep working.
18
+ //
19
+ // Threat model (stated plainly, matching the official dsh-fs-sandbox header):
20
+ // this is CONTAINMENT, not a security boundary. Paths are compared lexically, so
21
+ // symlinks and TOCTOU races inside the remote host can escape; a remote command
22
+ // can `cd` anywhere once bash runs with the user's own privileges. What it does
23
+ // buy, and what the issue asked for: a confined session can no longer mutate a
24
+ // remote tree outside its workspace BY ACCIDENT through the file tools, and
25
+ // `rw_exec` refuses outright under read-only instead of silently running
26
+ // anything. Deployments needing a hard boundary must confine on the REMOTE side
27
+ // (containers, per-user accounts, filesystem ACLs).
28
+
29
+ import path from 'node:path'
30
+ import { normalizeRemotePath, relPathUnder } from './paths.js'
31
+
32
+ /** The three file-effect modes, mirroring `@deepseek-ai/dsh-sandbox`. */
33
+ export const REMOTE_SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
34
+
35
+ /** Denial marker prefix, matching the official `sandboxDenialMarker` shape. */
36
+ const DENIAL_PREFIX = '[sandbox: '
37
+
38
+ /**
39
+ * Whether `target` lies inside `root` using lexical, separator-aware
40
+ * comparison. Mirrors the official `isPathInsideWorkspace`: both sides are
41
+ * normalized so `..` cannot escape, and `/tmp/foo` does not contain
42
+ * `/tmp/foobar`.
43
+ *
44
+ * A root of `/` contains everything (the whole filesystem is the workspace),
45
+ * which is what makes "workspace = filesystem root" a legal, permissive setup
46
+ * (issue #60) rather than a denial of everything.
47
+ *
48
+ * @param {string} target - the remote path being touched.
49
+ * @param {string} root - the remote workspace root.
50
+ * @returns {boolean}
51
+ */
52
+ export function isInsideRemote(target, root) {
53
+ if (typeof target !== 'string' || typeof root !== 'string') return false
54
+ // POSIX-absolute only. `normalizeRemotePath` deliberately coerces a relative
55
+ // input to an absolute one (`tmp/foo` → `/tmp/foo`, `../foo` → `/foo`), which
56
+ // is right for a user-supplied path but WRONG for a fence: it would turn an
57
+ // unprovable root into a confident one. A root that is not absolute proves
58
+ // nothing, so refuse before normalizing.
59
+ if (!isPosixAbsolute(root)) return false
60
+ const t = normalizeRemotePath(target)
61
+ let r = normalizeRemotePath(root)
62
+ if (r === '/' || r === '') return true
63
+ r = r.replace(/\/+$/, '')
64
+ if (t === r) return true
65
+ return t.startsWith(r + '/')
66
+ }
67
+
68
+ /**
69
+ * Whether a path is an absolute POSIX or Windows-Drive path, WITHOUT the
70
+ * coercing normalization `normalizeRemotePath` performs.
71
+ *
72
+ * @param {string} p
73
+ * @returns {boolean}
74
+ */
75
+ export function isPosixAbsolute(p) {
76
+ const s = String(p || '')
77
+ if (!s) return false
78
+ if (s.startsWith('/')) return true // POSIX
79
+ if (/^[a-zA-Z]:[\\/]/.test(s)) return true // Windows drive
80
+ if (/^\\\\[^\\]+/.test(s)) return true // Windows UNC
81
+ return false
82
+ }
83
+
84
+ /**
85
+ * Decide whether one remote operation is permitted under a mode.
86
+ *
87
+ * `operation` is the coarse kind the tools act with:
88
+ * - `read` — reading remote data (never denied: read-only still reads)
89
+ * - `write` — creating/replacing/removing a REMOTE PATH (path fence)
90
+ * - `exec` — running an arbitrary remote command
91
+ * - `tunnel` — opening a port forward
92
+ * - `connect`— dialing/authenticating a machine (never denied: without it
93
+ * nothing else could work, and it changes no remote state)
94
+ *
95
+ * @param {object} arg
96
+ * @param {string} arg.mode - the resolved sandbox mode.
97
+ * @param {string} arg.operation - see above.
98
+ * @param {string} [arg.path] - the remote path a `write` targets.
99
+ * @param {string} arg.workspaceRoot - the remote workspace root.
100
+ * @returns {{allowed: boolean, reason?: string, mode: string, subject?: string}}
101
+ */
102
+ export function checkRemoteSandbox({ mode, operation, path: target, workspaceRoot }) {
103
+ const m = REMOTE_SANDBOX_MODES.includes(mode) ? mode : 'read-only'
104
+ // Fail closed on an unknown mode, exactly like the official renderPolicyContext.
105
+ const effective = m
106
+
107
+ if (operation === 'read' || operation === 'connect') return { allowed: true, mode: effective }
108
+
109
+ if (effective === 'danger-full-access') return { allowed: true, mode: effective }
110
+
111
+ if (effective === 'read-only') {
112
+ // read-only refuses every state-changing remote operation. This is the
113
+ // strict reading of the official mode ("`read-only` permits only required
114
+ // sinks such as /dev/null") and the one that keeps a confined session
115
+ // honest on a machine whose filesystem it cannot police.
116
+ return {
117
+ allowed: false,
118
+ mode: effective,
119
+ subject: operation === 'exec' ? 'command' : operation === 'tunnel' ? 'tunnel' : 'operation',
120
+ reason:
121
+ operation === 'exec'
122
+ ? 'read-only sessions may not run commands on a remote host'
123
+ : operation === 'tunnel'
124
+ ? 'read-only sessions may not open remote port forwards'
125
+ : 'read-only sessions may not modify a remote host',
126
+ }
127
+ }
128
+
129
+ // workspace-write: writes must stay inside the workspace; exec and tunnel are
130
+ // allowed because a command's effects cannot be bounded lexically (and the
131
+ // official bash tool under workspace-write DOES run commands, confining them
132
+ // via the OS). Refusing exec here would make workspace-write useless for the
133
+ // primary remote use case (builds, tests), while pretending to confine it
134
+ // would be worse than saying we do not.
135
+ if (operation === 'exec' || operation === 'tunnel') return { allowed: true, mode: effective }
136
+
137
+ // A write with no resolvable workspace root cannot be proven inside it.
138
+ if (!workspaceRoot) {
139
+ return {
140
+ allowed: false,
141
+ mode: effective,
142
+ subject: 'operation',
143
+ reason: 'workspace-write needs a remote workspace root to fence writes; none is set',
144
+ }
145
+ }
146
+ if (isInsideRemote(target, workspaceRoot)) return { allowed: true, mode: effective }
147
+ return {
148
+ allowed: false,
149
+ mode: effective,
150
+ subject: 'operation',
151
+ reason: `workspace-write only permits changes inside ${workspaceRoot}`,
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Render a denial into the official error shape so the model sees the same
157
+ * thing it would from a local sandbox denial (and can use the same escalation
158
+ * affordances when the composition advertises them).
159
+ *
160
+ * @param {{mode: string, reason?: string, subject?: string}} denial
161
+ * @returns {Error}
162
+ */
163
+ export function remoteSandboxDenial(denial) {
164
+ const mode = (denial && denial.mode) || 'read-only'
165
+ const subject = (denial && denial.subject) || 'operation'
166
+ const hint = `[escalation: raise sandbox mode to retry the ${subject} once]`
167
+ const err = new Error(`${DENIAL_PREFIX}${mode}]\n${denial && denial.reason ? denial.reason + '\n' : ''}${hint}`)
168
+ err.code = 'FS_SANDBOX_DENIED'
169
+ err.remoteSandbox = { mode, subject }
170
+ return err
171
+ }
172
+
173
+ /**
174
+ * Relative path of a remote target under the workspace root, for diagnostics.
175
+ * Returns null when the target is outside it.
176
+ * @param {string} target
177
+ * @param {string} root
178
+ * @returns {string|null}
179
+ */
180
+ export function remoteRelUnder(target, root) {
181
+ const rel = relPathUnder(root, target)
182
+ return rel === null ? null : rel
183
+ }