Explorar o código

fix(ssh): disable signal-triggered helper inspection

Tianyi Cui hai 4 semanas
pai
achega
969e968e0e

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md
-2026-09-11-posix-ssh-runtime.md: f3fe3ac4aa3279d2821e075ec858cc6bfc7e9b27
-2026-09-11-posix-ssh-runtime.zh.md: 27bee34c8e1cc4a5c6f8cd34f603ba7a3eb27398
+2026-09-11-posix-ssh-runtime.md: 9f5de9d2efe09779989c54ba03b0b2b5badacf05
+2026-09-11-posix-ssh-runtime.zh.md: 76184c0d88764552626744ac5b138ff56fa28be3

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md

@@ -22,7 +22,7 @@ Each stream receives a fresh 256-bit TLS pre-shared key through private administ
 
 
 Collected stdout and stderr carry bounded tail snapshots through handlers that only update output observations. Capture continues while a snapshot is waiting for transport. Final snapshots preserve raw-byte offsets, and completed spill files use the local provider’s retained-output storage after connection disposal.
 Collected stdout and stderr carry bounded tail snapshots through handlers that only update output observations. Capture continues while a snapshot is waiting for transport. Final snapshots preserve raw-byte offsets, and completed spill files use the local provider’s retained-output storage after connection disposal.
 
 
-Readiness verifies the installed helper digest and, when PTC is configured, the installed Node bootstrap digest. These checks pin expected deployment artifacts; they do not authenticate a malicious remote operating system. File-effect confinement delegates to the remote local sandbox provider and retains its full/partial disclosure and platform limitations.
+Readiness verifies the installed helper digest and, when PTC is configured, the installed Node bootstrap digest. These checks pin expected deployment artifacts; they do not authenticate a malicious remote operating system. The helper disables Node debugger activation through `SIGUSR1`: file-effect confinement can still permit same-user signals, which must not expose the helper's unrestricted filesystem and process services. File-effect confinement delegates to the remote local sandbox provider and retains its full/partial disclosure and platform limitations.
 
 
 Path canonicalization belongs where the files exist. The shared policy resolver preserves absolute execution-world spelling; enforcing providers resolve symlinks and `..` on their own filesystem. Headless records and validates cwd through `ctx.fs`. Host-path projection remains unavailable for SSH, so Node execution requires an explicitly installed remote bootstrap.
 Path canonicalization belongs where the files exist. The shared policy resolver preserves absolute execution-world spelling; enforcing providers resolve symlinks and `..` on their own filesystem. Headless records and validates cwd through `ctx.fs`. Host-path projection remains unavailable for SSH, so Node execution requires an explicitly installed remote bootstrap.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md

@@ -22,7 +22,7 @@ SSH 提供经过认证的字节通道及逐通道流量控制,但普通 exec 
 
 
 收集的 stdout 与 stderr 通过仅更新输出观测的处理器传递有界尾部快照。快照等待传输时,输出捕获继续进行。最终快照保留原始字节偏移,已完成的 spill 文件在连接释放后使用本地提供方的保留输出存储。
 收集的 stdout 与 stderr 通过仅更新输出观测的处理器传递有界尾部快照。快照等待传输时,输出捕获继续进行。最终快照保留原始字节偏移,已完成的 spill 文件在连接释放后使用本地提供方的保留输出存储。
 
 
-就绪流程验证已安装辅助程序的摘要,并在配置 PTC 时验证已安装 Node 引导程序的摘要。这些检查固定预期部署产物,不用于认证恶意远端操作系统。文件效果限制委托给远端本地沙箱提供方,保留其完整/部分执行披露及平台限制。
+就绪流程验证已安装辅助程序的摘要,并在配置 PTC 时验证已安装 Node 引导程序的摘要。这些检查固定预期部署产物,不用于认证恶意远端操作系统。辅助程序禁用通过 `SIGUSR1` 启动 Node 调试器:文件效果限制仍可能允许同用户信号,这些信号不得暴露辅助程序不受限的文件系统和进程服务。文件效果限制委托给远端本地沙箱提供方,保留其完整/部分执行披露及平台限制。
 
 
 路径规范化属于文件实际存在的位置。共享策略解析器保留执行环境中的绝对路径写法;执行限制的提供方在自己的文件系统上解析符号链接与 `..`。headless 通过 `ctx.fs` 记录和验证 cwd。SSH 不提供主机路径投影,因此 Node 执行需要显式安装的远端引导程序。
 路径规范化属于文件实际存在的位置。共享策略解析器保留执行环境中的绝对路径写法;执行限制的提供方在自己的文件系统上解析符号链接与 `..`。headless 通过 `ctx.fs` 记录和验证 cwd。SSH 不提供主机路径投影,因此 Node 执行需要显式安装的远端引导程序。
 
 

+ 2 - 2
packages/ssh/ssh/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/ssh/ssh/README.md
 #   pnpm run verify-translation-pairing --write packages/ssh/ssh/README.md
-README.md: a9d60d0613912fb6e24c054eb8feb251bce65ec0
-README.zh.md: 69980708e58a2c8649343c1becc303588b5179f9
+README.md: f186ad1103e5b1f898953a545169fef312dd005e
+README.zh.md: 5b1afaf45bdcf089a095cd66f083e4928e3833c1

+ 2 - 0
packages/ssh/ssh/README.md

@@ -60,6 +60,8 @@ Each stream reservation has a random 256-bit TLS pre-shared key carried only by
 
 
 Connection disposal joins forwarding and cancellation subprocesses and partially established streams before removing local resources. Transport loss rejects pending operations and invalidates the connection. The helper starts managed cleanup on SSH EOF, termination signals or heartbeat expiry. A disconnected client cannot confirm the remote outcome; operations are never reconnected or replayed automatically.
 Connection disposal joins forwarding and cancellation subprocesses and partially established streams before removing local resources. Transport loss rejects pending operations and invalidates the connection. The helper starts managed cleanup on SSH EOF, termination signals or heartbeat expiry. A disconnected client cannot confirm the remote outcome; operations are never reconnected or replayed automatically.
 
 
+The helper starts with `--disable-sigusr1`, so a same-user process signal cannot open its Node debugger.
+
 </details>
 </details>
 
 
 -----
 -----

+ 2 - 0
packages/ssh/ssh/README.zh.md

@@ -60,6 +60,8 @@ OpenSSH 主连接承载私有管理 RPC。每条程序流使用独立转发的 U
 
 
 连接释放会先等待转发与取消子进程,以及尚在建立的流结束,再删除本地资源。传输丢失会拒绝待处理操作并使连接失效。辅助进程在 SSH EOF、终止信号或心跳到期时启动托管清理。断连客户端无法确认远端结果;操作不会自动重连或重放。
 连接释放会先等待转发与取消子进程,以及尚在建立的流结束,再删除本地资源。传输丢失会拒绝待处理操作并使连接失效。辅助进程在 SSH EOF、终止信号或心跳到期时启动托管清理。断连客户端无法确认远端结果;操作不会自动重连或重放。
 
 
+辅助程序以 `--disable-sigusr1` 启动,因此同用户进程发送的信号无法开启其 Node 调试器。
+
 </details>
 </details>
 
 
 -----
 -----

+ 2 - 2
packages/ssh/ssh/src/helper.ts

@@ -27,9 +27,9 @@ async function services() {
   const ctx = new Context()
   const ctx = new Context()
   const fibers = [await ctx.plugin(SessionProjectionRegistry)]
   const fibers = [await ctx.plugin(SessionProjectionRegistry)]
   fibers.push(await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() }))
   fibers.push(await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() }))
-  fibers.push(await ctx.plugin(SandboxedFileSystem, { cwd: process.cwd(), diffBasisMaxBytes: 10 * 1024 * 1024 }))
+  fibers.push(await ctx.plugin(SandboxedFileSystem, { cwd: process.cwd() }))
   fibers.push(await ctx.plugin(LocalSubprocessRuntime))
   fibers.push(await ctx.plugin(LocalSubprocessRuntime))
-  fibers.push(await ctx.plugin(LocalSandboxProvider, { runnerCommand: [], runnerFailureSignatures: [], probeTimeoutMs: 5000 }))
+  fibers.push(await ctx.plugin(LocalSandboxProvider))
   return { ctx, close: async () => { for (const fiber of fibers.reverse()) await fiber.dispose() } }
   return { ctx, close: async () => { for (const fiber of fibers.reverse()) await fiber.dispose() } }
 }
 }
 
 

+ 1 - 1
packages/ssh/ssh/src/index.ts

@@ -259,7 +259,7 @@ export class SshConnection extends Service {
     this.directory = await mkdtemp('/tmp/dsh-ssh-')
     this.directory = await mkdtemp('/tmp/dsh-ssh-')
     if (this.closed) throw new Error('SSH connection closed before startup')
     if (this.closed) throw new Error('SSH connection closed before startup')
     const quote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`
     const quote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`
-    const command = [this.config.node, this.config.helper].map(quote).join(' ')
+    const command = [this.config.node, '--disable-sigusr1', this.config.helper].map(quote).join(' ')
     const child = spawn('ssh', [
     const child = spawn('ssh', [
       '-T', '-M', '-S', this.controlPath(), '-o', 'ControlPersist=no', '-o', 'BatchMode=yes',
       '-T', '-M', '-S', this.controlPath(), '-o', 'ControlPersist=no', '-o', 'BatchMode=yes',
       '-o', 'StrictHostKeyChecking=yes', '-o', 'ForwardAgent=no', '-o', 'ClearAllForwardings=yes',
       '-o', 'StrictHostKeyChecking=yes', '-o', 'ForwardAgent=no', '-o', 'ClearAllForwardings=yes',

+ 125 - 0
packages/ssh/ssh/tests/helper-finalization.spec.ts

@@ -0,0 +1,125 @@
+/** Authenticated transport faults and competing native-range finalization. */
+import { once } from 'node:events'
+import { mkdtemp, readdir, rm } from 'node:fs/promises'
+import { createConnection, type Socket } from 'node:net'
+import { PassThrough } from 'node:stream'
+import { setImmediate } from 'node:timers/promises'
+import { createServer, type Server, type TLSSocket } from 'node:tls'
+import { Context } from '@deepseek-ai/cordis'
+import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
+import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
+import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { RemoteProcesses } from '../src/helper-processes.ts'
+import { authenticateStream } from '../src/stream-security.ts'
+
+vi.mock('node:tls', async (original) => {
+  const actual = await original<typeof import('node:tls')>()
+  return { ...actual, createServer: vi.fn(actual.createServer) }
+})
+
+function nativeProcess() {
+  const completion = Promise.withResolvers<SubprocessOutcome>()
+  const stdout = new PassThrough()
+  const stderr = new PassThrough()
+  const control = new PassThrough()
+  const finish = (): void => {
+    stdout.end(); stderr.end(); control.end()
+    completion.resolve({ exitCode: 0, signal: null })
+  }
+  const terminate = vi.fn(finish)
+  const waitForExit = vi.fn(async () => { await completion.promise; return true })
+  const handle: SubprocessHandle = {
+    stdin: undefined, stdout, stderr, control, collected: {}, done: completion.promise,
+    terminate, waitForExit,
+  }
+  return { handle, control, finish, terminate, waitForExit }
+}
+
+async function harness(child: ReturnType<typeof nativeProcess>, control: boolean) {
+  const root = await mkdtemp('/tmp/dsh-ssh-finalize-')
+  const ctx = new Context()
+  const owner = new RemoteProcesses(ctx, root, 1, 5000)
+  const sockets: Socket[] = []
+  onTestFinished(async () => {
+    child.finish()
+    for (const socket of [...sockets].reverse()) socket.destroy()
+    try { await owner.close() }
+    finally {
+      await ctx.fiber.dispose()
+      await rm(root, { recursive: true, force: true })
+      vi.restoreAllMocks()
+    }
+  })
+  await ctx.plugin(LocalFileSystem)
+  await ctx.plugin(LocalSubprocessRuntime)
+  vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
+  const prepared = await owner.prepare({
+    argv: ['fixture-native-process'], cwd: root, graceMs: 100,
+    stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', ...(control ? { control: 'pipe' } : {}) },
+  })
+  const serving: Record<string, TLSSocket> = {}
+  for (const [name, endpoint] of Object.entries(prepared.streams)) {
+    let server: Server | undefined
+    for (const result of vi.mocked(createServer).mock.results) {
+      if (result.type === 'return' && result.value.address() === endpoint.path) { server = result.value; break }
+    }
+    if (server === undefined) throw new Error('Fixture endpoint listener was not created')
+    const listener = server
+    const authenticated = new Promise<TLSSocket>((resolve) => { listener.once('secureConnection', resolve) })
+    const raw = createConnection({ path: endpoint.path, allowHalfOpen: true })
+    sockets.push(raw)
+    raw.on('error', () => {})
+    await once(raw, 'connect')
+    const socket = await authenticateStream(raw, endpoint.capability, 5000)
+    sockets.push(socket)
+    socket.on('error', () => {})
+    serving[name] = await authenticated
+    if (name !== 'control') { socket.end(); socket.resume() }
+  }
+  await owner.start(prepared.id)
+  return { root, owner, id: prepared.id, serving }
+}
+
+describe.skipIf(process.platform === 'win32')('SSH helper finalization ownership', () => {
+  it('destroys the native control endpoint when its authenticated TLS transport fails', async () => {
+    const child = nativeProcess()
+    const test = await harness(child, true)
+    const control = test.serving.control!
+    const closed = once(child.control, 'close')
+    expect(child.control.destroyed).toBe(false)
+    control.destroy(Object.assign(new Error('authenticated transport I/O failed'), { code: 'ECONNRESET' }))
+    await closed
+    expect(child.control.destroyed).toBe(true)
+    await test.owner.terminate(test.id)
+    expect(child.waitForExit).toHaveBeenCalled()
+    expect(await test.owner.done(test.id)).toMatchObject({ outcome: { exitCode: 0, signal: null } })
+  })
+
+  it('does not republish completion after helper close has released the process record', async () => {
+    const child = nativeProcess()
+    const observing = Promise.withResolvers<undefined>()
+    const releaseObservation = Promise.withResolvers<undefined>()
+    child.waitForExit.mockImplementationOnce(async () => {
+      observing.resolve(undefined)
+      await releaseObservation.promise
+      return true
+    })
+    const test = await harness(child, false)
+    onTestFinished(async () => {
+      await test.owner.close()
+      releaseObservation.resolve(undefined)
+      await setImmediate()
+    })
+    child.finish()
+    await observing.promise
+    await test.owner.close()
+    expect(child.terminate).toHaveBeenCalledOnce()
+    expect(child.waitForExit).toHaveBeenCalledTimes(2)
+    expect(await readdir(test.root)).toEqual([])
+    releaseObservation.resolve(undefined)
+    await setImmediate()
+    await expect(test.owner.done(test.id)).rejects.toThrow('Unknown or expired SSH process handle')
+    expect(await readdir(test.root)).toEqual([])
+  })
+})

+ 60 - 0
packages/ssh/ssh/tests/inspector-signal.spec.ts

@@ -0,0 +1,60 @@
+/** Same-user signals must not expose an unconfined helper's Node debugger. */
+import { spawn, spawnSync } from 'node:child_process'
+import { once } from 'node:events'
+import { mkdtemp, realpath, rm } from 'node:fs/promises'
+import { createInterface } from 'node:readline'
+import { Context } from '@deepseek-ai/cordis'
+import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
+import { describe, expect, it } from 'vitest'
+
+// This fixture has no credentials, application state, or descendants. Port zero
+// keeps the deliberately vulnerable control away from existing inspectors.
+const fixture = `
+process.stdin.on('data', () => setTimeout(() => process.stdout.write(JSON.stringify({ active: require('node:inspector').url() !== undefined }) + '\\n'), 100));
+process.stdin.on('end', () => process.exit(0));
+process.stdout.write('ready\\n');
+`
+
+describe.skipIf(process.platform !== 'darwin')('SSH helper inspector signal hardening', () => {
+  it.each([false, true])('observes actual debugger activation with SIGUSR1 disabled=%s', async (disabled) => {
+    const root = await realpath(await mkdtemp('/tmp/dsh-ssh-inspector-'))
+    const ctx = new Context()
+    const sandbox = await ctx.plugin(LocalSandboxProvider)
+    const child = spawn(process.execPath, [...(disabled ? ['--disable-sigusr1'] : []), '--inspect-port=0', '-e', fixture], {
+      cwd: root, env: {}, stdio: ['pipe', 'pipe', 'pipe'],
+    })
+    const closed = once(child, 'close')
+    const watchdog = setTimeout(() => { child.kill('SIGKILL') }, 5000)
+    const lines = createInterface({ input: child.stdout })
+    const replies = lines[Symbol.asyncIterator]()
+    const debuggerStarted = Promise.withResolvers<undefined>()
+    let diagnostics = ''
+    child.stderr.on('data', (bytes: Buffer) => {
+      diagnostics += bytes.toString()
+      if (diagnostics.includes('Debugger listening on ws://127.0.0.1:')) debuggerStarted.resolve(undefined)
+    })
+    try {
+      expect((await replies.next()).value).toBe('ready')
+      const signal = await ctx.sandbox.confine([process.execPath, '-e', `process.kill(${String(child.pid)}, 'SIGUSR1')`], {
+        mode: 'read-only', workspaceRoot: root,
+      })
+      const sender = spawnSync(signal.argv[0]!, signal.argv.slice(1), { cwd: root, env: {}, encoding: 'utf8', timeout: 3000 })
+      expect(sender.error).toBeUndefined()
+      expect(sender.status, sender.stderr).toBe(0)
+      if (!disabled) await Promise.race([
+        debuggerStarted.promise,
+        closed.then(() => { throw new Error('Control exited without starting its Inspector') }),
+      ])
+      child.stdin.write('inspect\n')
+      expect(JSON.parse((await replies.next()).value as string)).toEqual({ active: !disabled })
+      if (disabled) expect(diagnostics).not.toContain('Debugger listening')
+    } finally {
+      clearTimeout(watchdog)
+      lines.close()
+      child.kill('SIGTERM')
+      await closed
+      await sandbox.dispose()
+      await rm(root, { recursive: true, force: true })
+    }
+  }, 10_000)
+})

+ 1 - 1
packages/ssh/ssh/tests/startup-behavior.spec.ts

@@ -160,7 +160,7 @@ describe.skipIf(process.platform === 'win32')('SSH connection startup', () => {
     const argv = transport.spawn.mock.calls[0]?.[1] as string[]
     const argv = transport.spawn.mock.calls[0]?.[1] as string[]
     expect(argv).toContain('StrictHostKeyChecking=yes')
     expect(argv).toContain('StrictHostKeyChecking=yes')
     expect(argv).toContain('ForwardAgent=no')
     expect(argv).toContain('ForwardAgent=no')
-    expect(argv.at(-1)).toBe("'/remote/node' '/remote/helper'\\''s file.js'")
+    expect(argv.at(-1)).toBe("'/remote/node' '--disable-sigusr1' '/remote/helper'\\''s file.js'")
     expect(test.calls[0]?.params).toEqual({ protocol: 1, workspace: '/remote/workspace', leaseMs: 30_000, bootstrapPath: '/remote/process.js' })
     expect(test.calls[0]?.params).toEqual({ protocol: 1, workspace: '/remote/workspace', leaseMs: 30_000, bootstrapPath: '/remote/process.js' })
   })
   })