dsh-ssh-tunnel 1.0.1 → 1.0.2

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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Strict argument coercion for API/tool entry points.
3
+ *
4
+ * `String(x || fallback)` turns a missing argument into the literal string
5
+ * "undefined" (or the fallback), which then flows into path operations and
6
+ * silently creates files or remote paths named "undefined". These helpers
7
+ * throw instead, so the caller sees which argument is missing.
8
+ */
9
+
10
+ export function requireString(value, label) {
11
+ if (value == null) throw new Error(label + ' required')
12
+ const s = String(value).trim()
13
+ if (!s) throw new Error(label + ' required')
14
+ return s
15
+ }
16
+
17
+ /** Like requireString, but an empty value resolves to the fallback. */
18
+ export function stringOrDefault(value, fallback, label) {
19
+ if (value == null) return requireString(fallback, label)
20
+ const s = String(value).trim()
21
+ if (!s) return requireString(fallback, label)
22
+ return s
23
+ }
@@ -1,32 +1,32 @@
1
- import { createHash } from 'node:crypto'
2
-
3
- /** True when stored value is a sha256 hex digest (not legacy raw-key String(Buffer)). */
4
- export function isUsableHostFingerprint(fp) {
5
- return typeof fp === 'string' && /^[0-9a-f]{64}$/.test(fp.trim().toLowerCase())
6
- }
7
-
8
- /**
9
- * Normalize ssh2 hostVerifier input to a stable sha256 hex fingerprint.
10
- * - With connect option hostHash:'sha256', key is already a hex string.
11
- * - Raw Buffer (no hostHash) is hashed here as a safe fallback.
12
- */
13
- export function fingerprintFromHostKey(key) {
14
- if (key == null) return ''
15
- if (typeof key === 'string') {
16
- const s = key.trim().toLowerCase()
17
- if (/^[0-9a-f]{64}$/.test(s)) return s
18
- if (/^[0-9a-f]+$/.test(s) && s.length >= 32) return createHash('sha256').update(Buffer.from(s, 'hex')).digest('hex')
19
- return createHash('sha256').update(Buffer.from(key, 'utf8')).digest('hex')
20
- }
21
- if (typeof Buffer !== 'undefined' && Buffer.isBuffer(key)) {
22
- return createHash('sha256').update(key).digest('hex')
23
- }
24
- return createHash('sha256').update(Buffer.from(String(key))).digest('hex')
25
- }
26
-
27
- /** Human-facing label for trust prompts / logs. */
28
- export function formatHostFingerprint(fp, algo = 'SHA256') {
29
- if (!fp) return ''
30
- const hex = String(fp).trim().toLowerCase()
31
- return `${algo}:${hex}`
32
- }
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /** True when stored value is a sha256 hex digest (not legacy raw-key String(Buffer)). */
4
+ export function isUsableHostFingerprint(fp) {
5
+ return typeof fp === 'string' && /^[0-9a-f]{64}$/.test(fp.trim().toLowerCase())
6
+ }
7
+
8
+ /**
9
+ * Normalize ssh2 hostVerifier input to a stable sha256 hex fingerprint.
10
+ * - With connect option hostHash:'sha256', key is already a hex string.
11
+ * - Raw Buffer (no hostHash) is hashed here as a safe fallback.
12
+ */
13
+ export function fingerprintFromHostKey(key) {
14
+ if (key == null) return ''
15
+ if (typeof key === 'string') {
16
+ const s = key.trim().toLowerCase()
17
+ if (/^[0-9a-f]{64}$/.test(s)) return s
18
+ if (/^[0-9a-f]+$/.test(s) && s.length >= 32) return createHash('sha256').update(Buffer.from(s, 'hex')).digest('hex')
19
+ return createHash('sha256').update(Buffer.from(key, 'utf8')).digest('hex')
20
+ }
21
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(key)) {
22
+ return createHash('sha256').update(key).digest('hex')
23
+ }
24
+ return createHash('sha256').update(Buffer.from(String(key))).digest('hex')
25
+ }
26
+
27
+ /** Human-facing label for trust prompts / logs. */
28
+ export function formatHostFingerprint(fp, algo = 'SHA256') {
29
+ if (!fp) return ''
30
+ const hex = String(fp).trim().toLowerCase()
31
+ return `${algo}:${hex}`
32
+ }
@@ -1,75 +1,75 @@
1
- /** Credential presence without exposing secret material. */
2
- export function hostCredentialConfigured(host, secrets) {
3
- if (!host) return false
4
- if (host.authType === 'keyboardInteractive') return false
5
- const sec = (secrets && secrets.byHostId && secrets.byHostId[host.id]) || {}
6
- if (host.authType === 'privateKey') {
7
- return !!(host.privateKeyPath || sec.privateKeyPem)
8
- }
9
- return !!sec.password
10
- }
11
-
12
- /** UI-safe host record (never includes password/PEM). */
13
- export function publicHost(host, secrets) {
14
- const configured = hostCredentialConfigured(host, secrets)
15
- const sec = (secrets && secrets.byHostId && secrets.byHostId[host.id]) || {}
16
- return {
17
- id: host.id,
18
- name: host.name,
19
- host: host.host,
20
- port: host.port || 22,
21
- username: host.username,
22
- authType: host.authType || 'privateKey',
23
- privateKeyPath: host.privateKeyPath || '',
24
- source: host.source || 'manual',
25
- updatedAt: host.updatedAt || 0,
26
- credentialConfigured: configured,
27
- credentialStatus:
28
- host.authType === 'keyboardInteractive'
29
- ? 'unsupported'
30
- : configured
31
- ? 'saved'
32
- : 'missing',
33
- passwordConfigured: !!sec.password,
34
- privateKeyConfigured: !!(host.privateKeyPath || sec.privateKeyPem),
35
- }
36
- }
37
-
38
- /** Model-facing summary: no paths to key files beyond endpoint metadata. */
39
- export function modelHostSummary(host, secrets) {
40
- const p = publicHost(host, secrets)
41
- return {
42
- host_id: p.id,
43
- name: p.name,
44
- endpoint: `${p.username}@${p.host}:${p.port}`,
45
- username: p.username,
46
- host: p.host,
47
- port: p.port,
48
- authType: p.authType,
49
- credentialConfigured: p.credentialConfigured,
50
- credentialStatus: p.credentialStatus,
51
- }
52
- }
53
-
54
- /** Fail if object looks like it still carries raw secrets (for tests / asserts). */
55
- export function assertNoSecretFields(obj, label = 'object') {
56
- const banned = ['password', 'privateKeyPem', 'privateKey', 'passphrase', 'secret']
57
- const stack = [{ value: obj, path: label }]
58
- while (stack.length) {
59
- const { value, path } = stack.pop()
60
- if (!value || typeof value !== 'object') continue
61
- if (Array.isArray(value)) {
62
- value.forEach((v, i) => stack.push({ value: v, path: `${path}[${i}]` }))
63
- continue
64
- }
65
- for (const [k, v] of Object.entries(value)) {
66
- const key = k.toLowerCase()
67
- if (banned.includes(key) && v) {
68
- throw new Error(`${path}.${k} must not carry secret material`)
69
- }
70
- // allow *Configured boolean flags
71
- if (typeof v === 'object' && v) stack.push({ value: v, path: `${path}.${k}` })
72
- }
73
- }
74
- return true
75
- }
1
+ /** Credential presence without exposing secret material. */
2
+ export function hostCredentialConfigured(host, secrets) {
3
+ if (!host) return false
4
+ if (host.authType === 'keyboardInteractive') return false
5
+ const sec = (secrets && secrets.byHostId && secrets.byHostId[host.id]) || {}
6
+ if (host.authType === 'privateKey') {
7
+ return !!(host.privateKeyPath || sec.privateKeyPem)
8
+ }
9
+ return !!sec.password
10
+ }
11
+
12
+ /** UI-safe host record (never includes password/PEM). */
13
+ export function publicHost(host, secrets) {
14
+ const configured = hostCredentialConfigured(host, secrets)
15
+ const sec = (secrets && secrets.byHostId && secrets.byHostId[host.id]) || {}
16
+ return {
17
+ id: host.id,
18
+ name: host.name,
19
+ host: host.host,
20
+ port: host.port || 22,
21
+ username: host.username,
22
+ authType: host.authType || 'privateKey',
23
+ privateKeyPath: host.privateKeyPath || '',
24
+ source: host.source || 'manual',
25
+ updatedAt: host.updatedAt || 0,
26
+ credentialConfigured: configured,
27
+ credentialStatus:
28
+ host.authType === 'keyboardInteractive'
29
+ ? 'unsupported'
30
+ : configured
31
+ ? 'saved'
32
+ : 'missing',
33
+ passwordConfigured: !!sec.password,
34
+ privateKeyConfigured: !!(host.privateKeyPath || sec.privateKeyPem),
35
+ }
36
+ }
37
+
38
+ /** Model-facing summary: no paths to key files beyond endpoint metadata. */
39
+ export function modelHostSummary(host, secrets) {
40
+ const p = publicHost(host, secrets)
41
+ return {
42
+ host_id: p.id,
43
+ name: p.name,
44
+ endpoint: `${p.username}@${p.host}:${p.port}`,
45
+ username: p.username,
46
+ host: p.host,
47
+ port: p.port,
48
+ authType: p.authType,
49
+ credentialConfigured: p.credentialConfigured,
50
+ credentialStatus: p.credentialStatus,
51
+ }
52
+ }
53
+
54
+ /** Fail if object looks like it still carries raw secrets (for tests / asserts). */
55
+ export function assertNoSecretFields(obj, label = 'object') {
56
+ const banned = ['password', 'privateKeyPem', 'privateKey', 'passphrase', 'secret']
57
+ const stack = [{ value: obj, path: label }]
58
+ while (stack.length) {
59
+ const { value, path } = stack.pop()
60
+ if (!value || typeof value !== 'object') continue
61
+ if (Array.isArray(value)) {
62
+ value.forEach((v, i) => stack.push({ value: v, path: `${path}[${i}]` }))
63
+ continue
64
+ }
65
+ for (const [k, v] of Object.entries(value)) {
66
+ const key = k.toLowerCase()
67
+ if (banned.includes(key) && v) {
68
+ throw new Error(`${path}.${k} must not carry secret material`)
69
+ }
70
+ // allow *Configured boolean flags
71
+ if (typeof v === 'object' && v) stack.push({ value: v, path: `${path}.${k}` })
72
+ }
73
+ }
74
+ return true
75
+ }
@@ -1,59 +1,59 @@
1
- /**
2
- * Loopback / Origin checks for the local HTTP API.
3
- * 0.0.0.0 is NOT loopback (it is a bind-all address).
4
- */
5
-
6
- export function isLoopbackHostname(hostname) {
7
- const h = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase()
8
- return h === 'localhost' || h === '127.0.0.1' || h === '::1'
9
- }
10
-
11
- function originHostPort(originHeader) {
12
- try {
13
- const u = new URL(String(originHeader))
14
- const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase()
15
- const port = u.port || (u.protocol === 'https:' ? '443' : u.protocol === 'http:' ? '80' : '')
16
- return { host, port, hostHeaderLike: port ? `${host}:${port}` : host }
17
- } catch {
18
- return null
19
- }
20
- }
21
-
22
- /**
23
- * @param {object} req Node HTTP request
24
- * @param {string[]} trustedHosts
25
- */
26
- export function isTrustedRequest(req, trustedHosts) {
27
- try {
28
- const hostHeader = req.headers?.host || req.headers?.Host
29
- if (!hostHeader) return false
30
- const url = new URL('http://' + hostHeader)
31
- const reqHost = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
32
- const list = Array.isArray(trustedHosts) ? trustedHosts : []
33
- const hostOk =
34
- isLoopbackHostname(reqHost) ||
35
- list.some((entry) => {
36
- const e = String(entry)
37
- return e === hostHeader || e === url.hostname || e === url.host
38
- })
39
- if (!hostOk) return false
40
-
41
- const origin = req.headers?.origin || req.headers?.Origin
42
- if (!origin) return true
43
- const parsed = originHostPort(origin)
44
- if (!parsed) return false
45
- if (isLoopbackHostname(parsed.host) && isLoopbackHostname(reqHost)) {
46
- const reqPort = url.port || '80'
47
- if (parsed.port && parsed.port !== reqPort) return false
48
- return true
49
- }
50
- return list.some((entry) => {
51
- const e = String(entry)
52
- return e === parsed.hostHeaderLike || e === parsed.host || e === origin
53
- })
54
- } catch {
55
- return false
56
- }
57
- }
58
-
59
- export const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024
1
+ /**
2
+ * Loopback / Origin checks for the local HTTP API.
3
+ * 0.0.0.0 is NOT loopback (it is a bind-all address).
4
+ */
5
+
6
+ export function isLoopbackHostname(hostname) {
7
+ const h = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase()
8
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1'
9
+ }
10
+
11
+ function originHostPort(originHeader) {
12
+ try {
13
+ const u = new URL(String(originHeader))
14
+ const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase()
15
+ const port = u.port || (u.protocol === 'https:' ? '443' : u.protocol === 'http:' ? '80' : '')
16
+ return { host, port, hostHeaderLike: port ? `${host}:${port}` : host }
17
+ } catch {
18
+ return null
19
+ }
20
+ }
21
+
22
+ /**
23
+ * @param {object} req Node HTTP request
24
+ * @param {string[]} trustedHosts
25
+ */
26
+ export function isTrustedRequest(req, trustedHosts) {
27
+ try {
28
+ const hostHeader = req.headers?.host || req.headers?.Host
29
+ if (!hostHeader) return false
30
+ const url = new URL('http://' + hostHeader)
31
+ const reqHost = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
32
+ const list = Array.isArray(trustedHosts) ? trustedHosts : []
33
+ const hostOk =
34
+ isLoopbackHostname(reqHost) ||
35
+ list.some((entry) => {
36
+ const e = String(entry)
37
+ return e === hostHeader || e === url.hostname || e === url.host
38
+ })
39
+ if (!hostOk) return false
40
+
41
+ const origin = req.headers?.origin || req.headers?.Origin
42
+ if (!origin) return true
43
+ const parsed = originHostPort(origin)
44
+ if (!parsed) return false
45
+ if (isLoopbackHostname(parsed.host) && isLoopbackHostname(reqHost)) {
46
+ const reqPort = url.port || '80'
47
+ if (parsed.port && parsed.port !== reqPort) return false
48
+ return true
49
+ }
50
+ return list.some((entry) => {
51
+ const e = String(entry)
52
+ return e === parsed.hostHeaderLike || e === parsed.host || e === origin
53
+ })
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ export const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024
@@ -1,8 +1,6 @@
1
1
  import { lstatSync, realpathSync } from 'node:fs'
2
2
  import { dirname, isAbsolute, join, normalize, resolve as pathResolve } from 'node:path'
3
3
 
4
- export const WORKSPACE_ROOT = '/workspace'
5
-
6
4
  /**
7
5
  * Local roots the path guard accepts, most specific first.
8
6
  *
@@ -11,8 +9,11 @@ export const WORKSPACE_ROOT = '/workspace'
11
9
  * `/workspace` at all, and a fixed root rejects every real local path — which
12
10
  * breaks SFTP upload/download and the sidebar file browser. Relative local
13
11
  * paths resolve against the first root, so an explicit project root wins over
14
- * the `DSH_SSH_TUNNEL_WORKSPACE_ROOT` operator override and over the
15
- * `/workspace` fallback.
12
+ * the `DSH_SSH_TUNNEL_WORKSPACE_ROOT` operator override.
13
+ *
14
+ * There is deliberately no implicit fallback root: guessing a root (e.g. the
15
+ * host process cwd plus `/workspace`) silently widens the trust boundary.
16
+ * Callers that cannot name a project workspace must fail loudly instead.
16
17
  *
17
18
  * Read at call time so hosts and tests can set them after module load.
18
19
  */
@@ -21,7 +22,6 @@ export function workspaceRoots(explicitRoot = '') {
21
22
  if (explicitRoot && String(explicitRoot).trim()) raw.push(String(explicitRoot).trim())
22
23
  const env = process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT
23
24
  if (env && String(env).trim()) raw.push(String(env).trim())
24
- if (!raw.length) raw.push(WORKSPACE_ROOT)
25
25
  const roots = []
26
26
  for (const r of raw) {
27
27
  const key = normalizeProjectKey(r)
@@ -90,11 +90,11 @@ function realExisting(abs) {
90
90
  let probe = abs
91
91
  while (true) {
92
92
  try {
93
- return realpathSync(probe)
93
+ return { path: realpathSync(probe), probe }
94
94
  } catch (e) {
95
95
  if (e && e.code === 'ENOENT') {
96
96
  const parent = dirname(probe)
97
- if (!parent || parent === probe) return ''
97
+ if (!parent || parent === probe) return null
98
98
  probe = parent
99
99
  continue
100
100
  }
@@ -118,6 +118,11 @@ export function constrainToWorkspace(inputPath, { forUnlink = false, root = '' }
118
118
  throw new Error('local path required')
119
119
  }
120
120
  const roots = workspaceRoots(root)
121
+ if (!roots.length) {
122
+ throw new Error(
123
+ 'no local workspace root configured (open a project workspace or set DSH_SSH_TUNNEL_WORKSPACE_ROOT)',
124
+ )
125
+ }
121
126
  let abs = String(inputPath)
122
127
  if (!isAbsolute(abs)) abs = join(roots[0], abs)
123
128
  abs = pathResolve(abs)
@@ -134,23 +139,17 @@ export function constrainToWorkspace(inputPath, { forUnlink = false, root = '' }
134
139
  if (!(e && e.code === 'ENOENT')) throw e
135
140
  }
136
141
  }
137
- const real = realExisting(abs)
138
- if (real && !isPathInsideRoots(real, roots)) {
139
- throw new Error('local path escapes ' + roots.join(' or ') + ' via symlink')
142
+ const found = realExisting(abs)
143
+ if (found && found.path && !isPathInsideRoots(found.path, roots)) {
144
+ // A link was traversed only if the nearest existing ancestor resolves to a
145
+ // path other than its own lexical form; a missing leaf must not be
146
+ // reported as a symlink escape.
147
+ if (comparablePath(found.path) !== comparablePath(found.probe)) {
148
+ throw new Error('local path escapes ' + roots.join(' or ') + ' via symlink')
149
+ }
150
+ throw new Error('local path must be under ' + roots.join(' or '))
140
151
  }
141
152
  return abs
142
153
  }
143
154
 
144
- export function joinUnderRoot(root, relOrAbs) {
145
- const roots = workspaceRoots()
146
- const r = normalizeProjectKey(root) || roots[0]
147
- let abs = String(relOrAbs || r)
148
- if (!isAbsolute(abs)) abs = join(r, abs)
149
- abs = pathResolve(abs)
150
- if (!isPathInsideRoots(abs, [...roots, r])) {
151
- throw new Error('path outside allowed roots')
152
- }
153
- return constrainToWorkspace(abs)
154
- }
155
-
156
155
  export { isAbsolute, join, normalize, pathResolve }
@@ -1,41 +1,41 @@
1
- import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
3
-
4
- /**
5
- * Missing file → fallback. Corrupt JSON → throw (never treat as empty store).
6
- */
7
- export function readJsonFile(path, fallback) {
8
- if (!existsSync(path)) return fallback
9
- let raw
10
- try {
11
- raw = readFileSync(path, 'utf8')
12
- } catch (e) {
13
- throw new Error('cannot read ' + path + ': ' + (e && e.message ? e.message : e))
14
- }
15
- try {
16
- return JSON.parse(raw)
17
- } catch {
18
- throw new Error('corrupt json: ' + path)
19
- }
20
- }
21
-
22
- export function writeJsonAtomic(path, data, mode = 0o600) {
23
- const tmp = path + '.tmp'
24
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode })
25
- try { chmodSync(tmp, mode) } catch {}
26
- renameSync(tmp, path)
27
- try { chmodSync(path, mode) } catch {}
28
- }
29
-
30
- export function migrateMarkerPath(dataDir) {
31
- return join(dataDir, '.migrated-draft')
32
- }
33
-
34
- export function hostEndpointKey(host, port, username) {
35
- return `${String(username || '').trim()}@${String(host || '').trim()}:${Number(port) || 22}`
36
- }
37
-
38
- export function findExistingHostByEndpoint(hosts, host, port, username) {
39
- const key = hostEndpointKey(host, port, username)
40
- return (hosts || []).find((h) => hostEndpointKey(h.host, h.port, h.username) === key)
41
- }
1
+ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ /**
5
+ * Missing file → fallback. Corrupt JSON → throw (never treat as empty store).
6
+ */
7
+ export function readJsonFile(path, fallback) {
8
+ if (!existsSync(path)) return fallback
9
+ let raw
10
+ try {
11
+ raw = readFileSync(path, 'utf8')
12
+ } catch (e) {
13
+ throw new Error('cannot read ' + path + ': ' + (e && e.message ? e.message : e))
14
+ }
15
+ try {
16
+ return JSON.parse(raw)
17
+ } catch {
18
+ throw new Error('corrupt json: ' + path)
19
+ }
20
+ }
21
+
22
+ export function writeJsonAtomic(path, data, mode = 0o600) {
23
+ const tmp = path + '.tmp'
24
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode })
25
+ try { chmodSync(tmp, mode) } catch {}
26
+ renameSync(tmp, path)
27
+ try { chmodSync(path, mode) } catch {}
28
+ }
29
+
30
+ export function migrateMarkerPath(dataDir) {
31
+ return join(dataDir, '.migrated-draft')
32
+ }
33
+
34
+ export function hostEndpointKey(host, port, username) {
35
+ return `${String(username || '').trim()}@${String(host || '').trim()}:${Number(port) || 22}`
36
+ }
37
+
38
+ export function findExistingHostByEndpoint(hosts, host, port, username) {
39
+ const key = hostEndpointKey(host, port, username)
40
+ return (hosts || []).find((h) => hostEndpointKey(h.host, h.port, h.username) === key)
41
+ }
@@ -1,39 +1,47 @@
1
- /**
2
- * Result-delivery authorization gates for SSH session operations (security).
3
- * Pure helpers kept in shared/ so they are testable without an ssh2 install.
4
- *
5
- * Background (v0.4.3): a host grant can be revoked while an SSHManager
6
- * operation is already in flight. Closing the session stops the transport,
7
- * but a command can complete and a file can be pulled in the same tick as the
8
- * revocation. These gates are the *second* boundary: even if the transport
9
- * work finished, nothing from the revoked host may be delivered to the agent
10
- * (and no downloaded file may remain on disk).
11
- *
12
- * v0.4.4: missing predicate is fail-closed (never a silent no-op). HTTP and
13
- * write actions re-check the same gate.
14
- */
15
- import { unlinkSync } from 'node:fs'
16
-
17
- export function assertSessionStillAuthorized(session, isHostAuthorized) {
18
- if (!session) throw new Error('SSH session missing')
19
- if (typeof isHostAuthorized !== 'function') {
20
- throw new Error('SSH authorization predicate required')
21
- }
22
- if (session.projectPathKey && !isHostAuthorized(session.projectPathKey, session.hostId)) {
23
- throw new Error('SSH session host is not authorized for the current project.')
24
- }
25
- }
26
-
27
- export function discardRevokedLocalFile(session, localPath, isHostAuthorized) {
28
- if (!session) return false
29
- if (typeof isHostAuthorized !== 'function') {
30
- throw new Error('SSH authorization predicate required')
31
- }
32
- if (session.projectPathKey && !isHostAuthorized(session.projectPathKey, session.hostId)) {
33
- try {
34
- unlinkSync(localPath)
35
- } catch {}
36
- return true
37
- }
38
- return false
39
- }
1
+ /**
2
+ * Result-delivery authorization gates for SSH session operations (security).
3
+ * Pure helpers kept in shared/ so they are testable without an ssh2 install.
4
+ *
5
+ * Background (v0.4.3): a host grant can be revoked while an SSHManager
6
+ * operation is already in flight. Closing the session stops the transport,
7
+ * but a command can complete and a file can be pulled in the same tick as the
8
+ * revocation. These gates are the *second* boundary: even if the transport
9
+ * work finished, nothing from the revoked host may be delivered to the agent
10
+ * (and no downloaded file may remain on disk).
11
+ *
12
+ * v0.4.4: missing predicate is fail-closed (never a silent no-op). HTTP and
13
+ * write actions re-check the same gate.
14
+ *
15
+ * A session without a project binding is also denied: every creation path
16
+ * normalizes projectPathKey, so an empty binding means corrupted state, and
17
+ * skipping the comparison would silently authorize it.
18
+ */
19
+ import { unlinkSync } from 'node:fs'
20
+
21
+ export function assertSessionStillAuthorized(session, isHostAuthorized, deniedMessage) {
22
+ if (!session) throw new Error('SSH session missing')
23
+ if (typeof isHostAuthorized !== 'function') {
24
+ throw new Error('SSH authorization predicate required')
25
+ }
26
+ if (!isHostAuthorized(session.projectPathKey, session.hostId)) {
27
+ throw new Error(
28
+ typeof deniedMessage === 'string' && deniedMessage
29
+ ? deniedMessage
30
+ : 'SSH session host is not authorized for the current project.',
31
+ )
32
+ }
33
+ }
34
+
35
+ export function discardRevokedLocalFile(session, localPath, isHostAuthorized) {
36
+ if (!session) return false
37
+ if (typeof isHostAuthorized !== 'function') {
38
+ throw new Error('SSH authorization predicate required')
39
+ }
40
+ if (!isHostAuthorized(session.projectPathKey, session.hostId)) {
41
+ try {
42
+ unlinkSync(localPath)
43
+ } catch {}
44
+ return true
45
+ }
46
+ return false
47
+ }