dsh-remote 0.8.53 → 0.8.60

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/lib/pool.js CHANGED
@@ -20,6 +20,12 @@ const { Client } = ssh2
20
20
  * caller can keep using `new SshPool(...)`-shaped code — this factory only has
21
21
  * to decide which class to instantiate.
22
22
  */
23
+ /** Hard cap on proxy-chain depth. OpenSSH's ProxyJump has no explicit limit;
24
+ * this exists to turn a config CYCLE into a clear error instead of unbounded
25
+ * recursion. A legitimate chain is 1-3 hops.
26
+ * @type {number} */
27
+ export const MAX_JUMP_HOPS = 8
28
+
23
29
  export function createPool(config, opts) {
24
30
  return isSystemOpenSshBackend(config)
25
31
  ? new SystemOpenSshPool(config, opts)
@@ -168,6 +174,7 @@ export class ActivePool {
168
174
  connect() { return this.delegate.connect() }
169
175
  exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
170
176
  sftp() { return this.delegate.sftp() }
177
+ shell(opts) { return this.delegate.shell(opts) }
171
178
  detect() { return this.delegate.detect() }
172
179
  invalidate() { return this.delegate.invalidate() }
173
180
  close() { return this.delegate.close() }
@@ -284,19 +291,42 @@ export class SshPool {
284
291
  }
285
292
 
286
293
  // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
294
+ //
295
+ // MULTI-HOP (OpenSSH `ProxyJump a,b,c` semantics): each bastion may itself
296
+ // carry a `proxy`, and the chain is built from the FARTHEST hop inward — hop
297
+ // N is dialed through the already-open connection to hop N-1. The recursion
298
+ // IS the mechanism, so there is no depth limit beyond what the config
299
+ // expresses; MAX_JUMP_HOPS is a cycle/typo guard, not a feature limit.
287
300
  let sock = null
288
301
  const proxyCfg = this.config.proxy
289
302
  if (proxyCfg && proxyCfg.host) {
290
303
  try {
304
+ const depth = Number(this.config._hopDepth) || 0
305
+ if (depth >= MAX_JUMP_HOPS) {
306
+ throw new Error(`proxy chain is deeper than ${MAX_JUMP_HOPS} hops — check the machine config for a proxy cycle`)
307
+ }
291
308
  this.proxyPool = new SshPool({
292
309
  ...this.config,
293
310
  host: proxyCfg.host,
294
311
  port: Number(proxyCfg.port) || 22,
295
312
  username: proxyCfg.username || this.config.username || 'root',
313
+ // CREDENTIALS DO NOT INHERIT ACROSS HOPS. `...this.config` brings the
314
+ // TARGET's key/passphrase along, which is wrong in two ways: it may
315
+ // not be authorized on the bastion, and presenting the target's key to
316
+ // an intermediate host hands that host a credential it should never
317
+ // see. A hop with no key of its own falls back to the agent/default
318
+ // discovery path, which is what an operator's own ssh setup provides.
296
319
  password: proxyCfg.password || '',
297
320
  privateKeyPath: proxyCfg.privateKeyPath || '',
298
321
  passphrase: proxyCfg.passphrase || '',
299
- proxy: undefined,
322
+ ...(proxyCfg.useAgent !== undefined ? { useAgent: !!proxyCfg.useAgent } : {}),
323
+ ...(proxyCfg.keyboardInteractive !== undefined ? { keyboardInteractive: !!proxyCfg.keyboardInteractive } : {}),
324
+ ...(proxyCfg.hostKeyMode ? { hostKeyMode: proxyCfg.hostKeyMode } : {}),
325
+ // The nested hop keeps its OWN proxy — that is what builds the chain —
326
+ // and carries an incremented depth so a cycle is reported instead of
327
+ // recursing until the process dies.
328
+ proxy: proxyCfg.proxy && proxyCfg.proxy.host ? proxyCfg.proxy : undefined,
329
+ _hopDepth: depth + 1,
300
330
  }, { knownHostsFile: this._knownHostsFile })
301
331
  const pclient = await this.proxyPool.connect()
302
332
  if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
@@ -347,9 +377,16 @@ export class SshPool {
347
377
  hostVerifier: (key) => guard.verifier(key),
348
378
  }
349
379
  if (sock) opts.sock = sock
380
+ // `useAgent` asks for agent auth, but ssh2 can only offer it when this
381
+ // PROCESS has an agent socket. On Windows there is no agent by default,
382
+ // so the flag alone used to fall through to the generic "no credentials"
383
+ // error — which reads as "the plugin lost my settings" rather than "this
384
+ // machine has no agent". Track the intent so the failure can say which.
385
+ let agentRequested = false
350
386
  if (this.config.useAgent) {
351
387
  const sockPath = process.env.SSH_AUTH_SOCK
352
388
  if (sockPath) opts.agent = sockPath
389
+ else agentRequested = true
353
390
  }
354
391
  let password = this.config.password || ''
355
392
  if (!password && this.passwordResolver) {
@@ -375,7 +412,9 @@ export class SshPool {
375
412
  opts.privateKey = key
376
413
  opts.passphrase = this.config.passphrase || undefined
377
414
  } else if (!password && !opts.agent) {
378
- throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
415
+ throw new Error(agentRequested
416
+ ? 'no SSH agent available: useAgent is enabled for this machine but SSH_AUTH_SOCK is not set in the DSH host process (Windows has no agent by default) — start an agent (or Pageant) before launching DSH, or save a password / private key for this machine'
417
+ : 'no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
379
418
  }
380
419
  return opts
381
420
  }
@@ -643,6 +682,60 @@ export class SshPool {
643
682
  )
644
683
  }
645
684
 
685
+ /**
686
+ * Open an INTERACTIVE shell channel on the remote host (PTY-backed).
687
+ *
688
+ * This is what an embedded terminal needs and what `exec` cannot provide:
689
+ * `exec` runs one command and closes, with no PTY and no stdin after the
690
+ * writer returns. A shell channel keeps the session alive across commands, so
691
+ * `vim`, `top`, `sudo` prompts and anything else that talks to a terminal
692
+ * works the way it does in an ssh session.
693
+ *
694
+ * The returned stream is ssh2's channel: write to it for keystrokes, listen
695
+ * for `data` for output, call `setWindow(rows, cols, height, width)` on
696
+ * resize, and `end()`/`close()` to hang up. Columns/rows are negotiated per
697
+ * channel, so two terminals on the same pooled connection stay independent.
698
+ *
699
+ * @param {{cols?: number, rows?: number, term?: string, pty?: boolean}} [opts]
700
+ * @returns {Promise<object>} the ssh2 Channel (interactive stream).
701
+ */
702
+ shell(opts = {}) {
703
+ const cols = Math.max(2, Math.min(Number(opts.cols) || 80, 1000))
704
+ const rows = Math.max(2, Math.min(Number(opts.rows) || 24, 1000))
705
+ const term = String(opts.term || 'xterm-256color')
706
+ return this.connect().then(
707
+ (client) =>
708
+ new Promise((resolve, reject) => {
709
+ let retried = false
710
+ const runOn = (c) => {
711
+ const wndopts = { cols, rows, height: 0, width: 0, term }
712
+ // A shell on a Windows remote is a cmd/PowerShell via the default
713
+ // sshd shell; the PTY request is identical either way.
714
+ c.shell(wndopts, opts.pty === false ? {} : { pty: wndopts }, (err, stream) => {
715
+ if (err) {
716
+ // Same stale-connection recovery as exec/sftp: a session
717
+ // termination means the pooled client died server-side.
718
+ if (!retried && /channel open failure|open failed|session termination|disconnect/i.test(String((err && err.message) || err))) {
719
+ retried = true
720
+ this.invalidate()
721
+ return this.connect().then(
722
+ (fresh) => runOn(fresh),
723
+ (e2) => reject(new Error('ssh shell failed (reconnect): ' + ((e2 && e2.message) || e2))),
724
+ )
725
+ }
726
+ return reject(new Error('ssh shell failed: ' + ((err && err.message) || err)))
727
+ }
728
+ // An idle shell must not be killed by the connection's own idle
729
+ // handling: keepalive is already on, and the channel lives until
730
+ // the client calls end(). Nothing to schedule here.
731
+ resolve(stream)
732
+ })
733
+ }
734
+ runOn(client)
735
+ }),
736
+ )
737
+ }
738
+
646
739
  /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
647
740
  sftp() {
648
741
  return this.connect().then(
package/lib/registry.js CHANGED
@@ -58,15 +58,213 @@ export function saveMachines(file, list, currentId, keepCurrentKey = true) {
58
58
  writeFileSync(file, JSON.stringify(payload, null, 2))
59
59
  }
60
60
 
61
- /** Strip secrets before sending a machine to the client. */
61
+ /**
62
+ * Does this record actually HAVE a password the plugin can use?
63
+ *
64
+ * `passwordSet` used to be `!!m.password`, i.e. "a PLAINTEXT password exists in
65
+ * machines.json". A machine whose password lives in the OS keychain stores
66
+ * `password: ''` (by design — issue #30), so it reported `passwordSet: false`
67
+ * and the settings page showed an empty password box with no indication that a
68
+ * credential was saved. That is the difference between "the plugin forgot my
69
+ * password" and "the plugin has it and will not echo it".
70
+ *
71
+ * `secretStored` is written by the save route from the keychain result, so new
72
+ * records carry the truth. Records saved before that flag existed only carry the
73
+ * backend name, which is the best evidence available for them (a non-plain
74
+ * backend means a secret was meant to live in the OS store).
75
+ *
76
+ * @param {object} m - a registry record.
77
+ * @returns {boolean}
78
+ */
79
+ export function hasStoredPassword(m) {
80
+ if (!m) return false
81
+ if (m.password) return true
82
+ if (m.secretStored === true) return true
83
+ if (m.secretStored === undefined && m.credentialBackend && m.credentialBackend !== 'plain') return true
84
+ return false
85
+ }
86
+
87
+ /** Strip secrets before sending a machine to the client.
88
+ *
89
+ * Secrets never round-trip to the browser: a password is replaced by
90
+ * `passwordSet`, and the private-key `passphrase` — which IS stored in the
91
+ * record — is replaced by `passphraseSet`. Both flags exist so the settings form
92
+ * can say "已保存(••••••••),留空 = 不修改" instead of showing an empty box that
93
+ * looks like the plugin lost the value. */
62
94
  export function sanitizeMachine(m) {
63
95
  if (!m) return m
64
- const { password, proxy, ...rest } = m
65
- const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
66
- if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
96
+ const { password, passphrase, proxy, ...rest } = m
97
+ const out = {
98
+ ...rest,
99
+ passwordSet: hasStoredPassword(m),
100
+ passphraseSet: !!(passphrase && String(passphrase).length),
101
+ }
102
+ if (proxy) {
103
+ out.proxy = {
104
+ ...proxy,
105
+ password: proxy.password ? '' : undefined,
106
+ passwordSet: !!(proxy.password && proxy.password.length),
107
+ }
108
+ }
109
+ return out
110
+ }
111
+
112
+ /**
113
+ * Resolve the credentials a TEST probe must dial with (issue: "编辑已连接机器 → 测试
114
+ * 连接" always failed with `no credentials`).
115
+ *
116
+ * The settings form never receives stored secrets, so an edit-then-test posted an
117
+ * EMPTY password while a real connect (which resolves the keychain/plain secret
118
+ * through the pool's `passwordResolver`) worked fine. The probe therefore has to
119
+ * be a MERGE: the form is an override layer, and anything it leaves blank falls
120
+ * back to what is stored for that machine.
121
+ *
122
+ * Rules, deliberately one-way (a form value never gets discarded):
123
+ * · a non-empty form string wins;
124
+ * · an empty/absent form string keeps the stored value;
125
+ * · booleans (`useAgent`, `keyboardInteractive`) are taken from the form when
126
+ * the form actually supplied a boolean, otherwise from the record — the
127
+ * probe must exercise the SAME auth method the machine will use;
128
+ * · a jump-host (`proxy`) password the form did not re-supply is carried over
129
+ * from the stored proxy, otherwise a bastion-protected machine "fails to
130
+ * test" for a reason that has nothing to do with the target.
131
+ *
132
+ * @param {object} body - the probe request body (form values).
133
+ * @param {object|null} rec - the saved machine record, if any.
134
+ * @param {string} [storedPassword] - the record's resolvable password (may come
135
+ * from the OS keychain, hence the separate argument).
136
+ * @returns {{password:string,passphrase:string,privateKeyPath:string,useAgent:boolean,
137
+ * keyboardInteractive:boolean,hostKeyMode:string,sshBinary:string,backend:string,
138
+ * proxy:object|undefined,usedSavedCredential:boolean}}
139
+ */
140
+ export function resolveProbeCredentials(body = {}, rec = null, storedPassword = '') {
141
+ const form = body || {}
142
+ const saved = rec || {}
143
+ const str = (v) => (v === undefined || v === null ? '' : String(v))
144
+ const pick = (formVal, storedVal) => {
145
+ const f = str(formVal).trim()
146
+ return f !== '' ? str(formVal) : str(storedVal)
147
+ }
148
+ const pickBool = (formVal, storedVal) => (typeof formVal === 'boolean' ? formVal : !!storedVal)
149
+
150
+ const formPassword = str(form.password)
151
+ const password = formPassword !== '' ? formPassword : str(storedPassword)
152
+ const formProxy = form.proxy && form.proxy.host ? form.proxy : null
153
+ const savedProxy = saved.proxy && saved.proxy.host ? saved.proxy : null
154
+ const baseProxy = formProxy || savedProxy
155
+ const proxy = baseProxy
156
+ ? {
157
+ host: String(baseProxy.host),
158
+ port: Number(baseProxy.port) || 22,
159
+ username: str(baseProxy.username),
160
+ password: formProxy && str(formProxy.password) !== ''
161
+ ? str(formProxy.password)
162
+ : str(savedProxy && savedProxy.password),
163
+ privateKeyPath: str(baseProxy.privateKeyPath),
164
+ }
165
+ : undefined
166
+
167
+ return {
168
+ password,
169
+ passphrase: pick(form.passphrase, saved.passphrase),
170
+ privateKeyPath: pick(form.privateKeyPath, saved.privateKeyPath),
171
+ useAgent: pickBool(form.useAgent, saved.useAgent),
172
+ keyboardInteractive: pickBool(form.keyboardInteractive, saved.keyboardInteractive),
173
+ hostKeyMode: pick(form.hostKeyMode, saved.hostKeyMode),
174
+ sshBinary: pick(form.sshBinary, saved.sshBinary),
175
+ backend: pick(form.backend, saved.backend) || 'native',
176
+ proxy,
177
+ // True when the form could not supply the credential and the stored one had
178
+ // to be used — the UI says so, because "测试成功" with an empty password box
179
+ // is otherwise indistinguishable from a form-only probe.
180
+ usedSavedCredential: formPassword === '' && password !== '',
181
+ }
182
+ }
183
+
184
+ /** Machine grouping metadata (host organisation).
185
+ *
186
+ * A flat list does not survive contact with reality: once a user has a dozen
187
+ * machines (prod/staging/GPU boxes/laptops) "pick the right one" becomes the
188
+ * dominant failure mode, and picking the WRONG one on a destructive tool is
189
+ * how accidents happen. These four fields are pure LABELS — they change no
190
+ * connection behaviour — so they live beside it and can be edited freely.
191
+ *
192
+ * Normalization is deliberately conservative: a bad value is dropped (not
193
+ * coerced into something surprising), and every field is optional so existing
194
+ * registries keep loading byte-for-byte.
195
+ *
196
+ * @param {object} raw - a (possibly partial) group patch.
197
+ * @returns {{environment?: string, location?: string, description?: string, tags?: string[]}}
198
+ */
199
+ export function normalizeMachineGroup(raw) {
200
+ const out = {}
201
+ if (!raw || typeof raw !== 'object') return out
202
+ const str = (v, max) => {
203
+ const s = typeof v === 'string' ? v.trim() : ''
204
+ return s ? s.slice(0, max) : ''
205
+ }
206
+ const env = str(raw.environment, 40)
207
+ if (env) out.environment = env
208
+ const loc = str(raw.location, 40)
209
+ if (loc) out.location = loc
210
+ const desc = str(raw.description, 200)
211
+ if (desc) out.description = desc
212
+ // Tags: accept an array or a comma/space separated string (what a UI text box
213
+ // naturally produces), then de-duplicate case-insensitively while KEEPING the
214
+ // first spelling so the display stays stable.
215
+ let tags = raw.tags
216
+ if (typeof tags === 'string') tags = tags.split(/[,\s]+/)
217
+ if (Array.isArray(tags)) {
218
+ const seen = new Set()
219
+ const kept = []
220
+ for (const t of tags) {
221
+ const s = str(t, 32)
222
+ if (!s) continue
223
+ const k = s.toLowerCase()
224
+ if (seen.has(k)) continue
225
+ seen.add(k)
226
+ kept.push(s)
227
+ if (kept.length >= 12) break // a dozen labels is already too many to scan
228
+ }
229
+ if (kept.length) out.tags = kept
230
+ }
67
231
  return out
68
232
  }
69
233
 
234
+ /**
235
+ * Select machines by group metadata — the predicate behind "run this on every
236
+ * staging box". All supplied filters must match (AND); tags are OR within
237
+ * themselves (any requested tag qualifies) which is how people read a tag row.
238
+ *
239
+ * @param {Array<object>} machines
240
+ * @param {{environment?: string, location?: string, tags?: string[]|string, query?: string}} filter
241
+ * @returns {Array<object>}
242
+ */
243
+ export function filterMachinesByGroup(machines, filter = {}) {
244
+ const list = Array.isArray(machines) ? machines : []
245
+ const env = typeof filter.environment === 'string' ? filter.environment.trim().toLowerCase() : ''
246
+ const loc = typeof filter.location === 'string' ? filter.location.trim().toLowerCase() : ''
247
+ let wantTags = filter.tags
248
+ if (typeof wantTags === 'string') wantTags = wantTags.split(/[,\s]+/)
249
+ wantTags = (Array.isArray(wantTags) ? wantTags : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
250
+ const query = typeof filter.query === 'string' ? filter.query.trim().toLowerCase() : ''
251
+ return list.filter((m) => {
252
+ if (!m) return false
253
+ if (env && String(m.environment || '').toLowerCase() !== env) return false
254
+ if (loc && String(m.location || '').toLowerCase() !== loc) return false
255
+ if (wantTags.length) {
256
+ const have = (Array.isArray(m.tags) ? m.tags : []).map((t) => String(t).toLowerCase())
257
+ if (!wantTags.some((t) => have.includes(t))) return false
258
+ }
259
+ if (query) {
260
+ const hay = [m.name, m.host, m.username, m.environment, m.location, m.description, ...(Array.isArray(m.tags) ? m.tags : [])]
261
+ .filter(Boolean).join(' ').toLowerCase()
262
+ if (!hay.includes(query)) return false
263
+ }
264
+ return true
265
+ })
266
+ }
267
+
70
268
  /** Apply a machine's fields onto the live config object (pool + tools read it). */
71
269
  export function applyMachine(config, m) {
72
270
  if (!m) return
@@ -0,0 +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
+ }
package/lib/routes-fs.js CHANGED
@@ -37,7 +37,17 @@ export function createFsRoutes({
37
37
  path: '/dsh-remote/ls',
38
38
  handler: async (req, res) => {
39
39
  try {
40
- const hint = requestSessionHint(req)
40
+ // A POST body counts. This route used to read the session hint from the
41
+ // QUERY STRING only, so `POST /ls {sessionId}` silently listed the ACTIVE
42
+ // machine instead of the caller's — the wrong-host hazard issue #25 is
43
+ // about, reachable through the API even though the browser always uses
44
+ // GET. `/read`, `/write` and `/fs` already honoured the body; now this one
45
+ // does too, and `path` may come from either place.
46
+ let body = {}
47
+ if (req.method === 'POST') {
48
+ try { body = JSON.parse((await readBody(req)) || '{}') } catch { body = {} }
49
+ }
50
+ const hint = requestSessionHint(req, body)
41
51
  const b = await bindOrReply(req, res, hint)
42
52
  if (!b) return
43
53
  // Identity echo (external-switch defense): name the machine this
@@ -53,7 +63,9 @@ export function createFsRoutes({
53
63
  }
54
64
  await b.pool.detect()
55
65
  const q = new URL(req.url, 'http://localhost').searchParams
56
- const raw = q.get('path') ? decodeURIComponent(q.get('path')) : (b.ws || '')
66
+ const raw = q.get('path')
67
+ ? decodeURIComponent(q.get('path'))
68
+ : (body.path ? String(body.path) : (b.ws || ''))
57
69
  const canon = normalizeRemotePath(raw)
58
70
  const win = b.pool.platform === 'windows'
59
71
  if (canon === '/' || raw === '' || raw === '/') {