refactor(sandbox): await cancellable process preparation
This commit is contained in:
1 parent
72226bd061
commit
32de4412dc
11 files changed
+93
-94
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# 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:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md
|
||||
2026-09-11-posix-ssh-runtime.md: cdb2a50d410c55e739fbf4a71216f7ce2b6751df
|
||||
2026-09-11-posix-ssh-runtime.zh.md: 263aedfd115a8b0ec4904619e14078408334321d
|
||||
2026-09-11-posix-ssh-runtime.md: 50d69b731a6deac8cf7c9afd42ffa3d3138b979e
|
||||
2026-09-11-posix-ssh-runtime.zh.md: 9ddeec2a5e7627c77b4cf75ae09150c183c6d681
|
||||
@@ -14,6 +14,8 @@ SSH supplies authenticated byte channels and per-channel flow control, but an or
|
||||
|
||||
A deployment-owned OpenSSH alias connects the local Harness to an installed POSIX helper. Filesystem, subprocess and sandbox providers share that helper; the Harness retains Cordis objects, model transport, permissions, callbacks and Session persistence. Terminal methods remain asynchronous. Provider paths describe the execution world without a separate local/remote flag.
|
||||
|
||||
Confinement is asynchronous and cancellable: the running helper resolves each policy through its loaded sandbox provider before the subprocess provider receives literal argv. `ShellExecutor.start()` resolves a `Promise<ShellProcess>` after preparation. Generic job admission remains synchronous; tool-owned `JobHooks` begin asynchronous shell preparation after preflight, cancel pending preparation and join any late process handle.
|
||||
|
||||
Private administrative RPC and each program stream use independent SSH channels. A remote reservation creates the requested stream endpoints before the payload starts. Stdout and stderr cannot carry administrative replies, and pausing one output channel does not consume fd 7’s flow-control window. This trades a larger remote helper for explicit binary transport and bounded stream retention.
|
||||
|
||||
Each stream receives a fresh 256-bit TLS pre-shared key through private administrative RPC. TLS 1.2 with `PSK-AES256-GCM-SHA384` authenticates both endpoints and protects subsequent bytes before the stream can publish. The key never travels as a stream preface. Socket permissions alone are insufficient: file-effect confinement can permit same-user connections or pathname replacement in writable temporary directories. No administrative Unix listener or stream secret is exposed to the payload.
|
||||
|
||||
@@ -14,6 +14,8 @@ SSH 提供经过认证的字节通道及逐通道流量控制,但普通 exec
|
||||
|
||||
部署方持有的 OpenSSH 主机别名将本地 Harness 连接到已安装的 POSIX 辅助程序。文件系统、子进程及沙箱提供方共享该辅助程序;Harness 保留 Cordis 对象、模型传输、权限、回调与 Session 持久化。终端方法保持异步。提供方路径描述执行环境,无需额外的本地/远端标记。
|
||||
|
||||
限制解析支持异步与取消:正在运行的辅助程序通过已加载的沙箱提供方解析每次策略,然后子进程提供方取得可直接执行的 argv。`ShellExecutor.start()` 在准备完成后返回 `Promise<ShellProcess>`。通用任务准入保持同步;由工具负责的 `JobHooks` 在预检后启动异步 shell 准备,取消待完成的准备并等待任何延迟出现的进程句柄结束。
|
||||
|
||||
私有管理 RPC 与各条程序流使用独立 SSH 通道。远端预留操作在程序启动前创建请求的流端点。stdout 与 stderr 无法承载管理回复,暂停一条输出通道也不会占用 fd 7 的流控窗口。这用更大的远端辅助程序换取显式二进制传输与有界流保留。
|
||||
|
||||
每条流通过私有管理 RPC 获得新的 256 位 TLS 预共享密钥。TLS 1.2 配合 `PSK-AES256-GCM-SHA384` 在流发布前认证两端并保护后续字节。密钥绝不作为流前缀传输。仅靠套接字权限不足:文件效果限制可能允许同用户连接,或允许在可写临时目录中替换路径名。程序不会获得管理 Unix 监听端点或流密钥。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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:
|
||||
# pnpm run verify-translation-pairing --write packages/ssh/sandbox-ssh/README.md
|
||||
README.md: e7ec7eb90e1befde48335a891d3afbb232b3a760
|
||||
README.zh.md: 41e1aa2689d9d06494351dd5ab9aeefc75435fc3
|
||||
README.md: e8f6cb8d47288e92fe8082ae23d550dff344324d
|
||||
README.zh.md: 694ff99cb0a5df0c45392de0211df2dfa223070d
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount this provider with the shared [`SSH connection`](../ssh/README.md), SSH filesystem and SSH subprocess providers. It has no package-specific configuration. The service verifies the remote backend before the composition becomes ready, then exposes synchronous `confine()` using those established facts.
|
||||
Mount this provider with the shared [`SSH connection`](../ssh/README.md), SSH filesystem and SSH subprocess providers. It has no package-specific configuration. Await `confine(argv, policy, signal)` to resolve each policy and command through the running remote helper.
|
||||
|
||||
Pass a complete `read-only` or `workspace-write` policy. The workspace is interpreted and canonicalized on the remote host. Consumers bypass `confine()` for `danger-full-access`; the connection does not invent an additional local/remote policy flag.
|
||||
|
||||
@@ -37,7 +37,7 @@ Pass a complete `read-only` or `workspace-write` policy. The workspace is interp
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The wrapper invokes the installed helper’s private confinement path, which rechecks the selected runner, enforcement level and denial signatures before running the payload. A changed backend refuses execution rather than returning stale enforcement facts. The subprocess provider remains responsible for fd 7 transport and managed process lifetime.
|
||||
The running helper canonicalizes the policy root and asks its loaded sandbox provider for the enforcing argv, enforcement level and runner-failure evidence. The SSH subprocess provider executes that returned argv directly and owns fd 7 transport and managed process lifetime. Cancellation or an unavailable backend rejects before the caller receives a command to launch.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
将本提供方与共享 [SSH 连接](../ssh/README.zh.md)、SSH 文件系统及 SSH 子进程提供方一同挂载。本包没有专用配置。服务在组合就绪前验证远端后端,再基于已确认事实提供同步 `confine()`。
|
||||
将本提供方与共享 [SSH 连接](../ssh/README.zh.md)、SSH 文件系统及 SSH 子进程提供方一同挂载。本包没有专用配置。等待 `confine(argv, policy, signal)`,由正在运行的远端辅助程序解析每次调用的策略与命令。
|
||||
|
||||
传入完整的 `read-only` 或 `workspace-write` 策略。工作区路径在远端主机解释并规范化。消费方在 `danger-full-access` 模式下绕过 `confine()`;连接不额外引入本地/远端策略标记。
|
||||
|
||||
@@ -37,7 +37,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
包装命令调用已安装辅助程序的私有限制入口,在执行程序前重新核对所选运行器、执行完整性等级及拒绝特征。后端发生变化时拒绝执行,避免返回过期的执行信息。fd 7 传输及托管进程生命周期仍由子进程提供方负责。
|
||||
正在运行的辅助程序规范化策略根路径,再向已加载的沙箱提供方获取强制执行限制的 argv、执行完整性等级与运行器失败证据。SSH 子进程提供方直接执行返回的 argv,并负责 fd 7 传输及托管进程生命周期。取消请求或后端不可用时,会在调用方取得可启动命令之前拒绝请求。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/** Remote argv wrapper that selects and applies the sandbox on the SSH host. */
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-ssh'
|
||||
@@ -7,32 +6,26 @@ import { z } from 'zod'
|
||||
|
||||
const factsSchema = z.object({ argv: z.array(z.string()).min(1), enforcement: z.enum(['full', 'partial']), denialSignatures: z.array(z.string()), runnerFailureRules: z.array(z.object({ allowedExitCodes: z.array(z.number().int()).optional(), fatalSignatures: z.array(z.string()), informationalLines: z.array(z.string()).optional() }).strict()) }).strict()
|
||||
|
||||
/** Synchronous wrapping uses verified remote backend facts established before the composition becomes ready. */
|
||||
/** Resolve each confinement request on the same host as its filesystem and subprocess providers. */
|
||||
export class SshSandboxProvider extends SandboxProvider {
|
||||
static inject = ['ssh']
|
||||
private facts: z.infer<typeof factsSchema> | undefined
|
||||
private node: string | undefined
|
||||
|
||||
/** Verify the remote backend before exposing synchronous argv wrapping. */
|
||||
async [Service.init](): Promise<void> {
|
||||
const hello = await this.ctx.ssh.ready
|
||||
this.node = hello.node
|
||||
this.facts = await this.ctx.ssh.request('sandbox', { argv: ['true'], policy: { mode: 'read-only', workspaceRoot: hello.workspace } }, factsSchema)
|
||||
}
|
||||
|
||||
override confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
if (this.facts === undefined || this.node === undefined) throw new SandboxUnavailableError(policy.mode, 'SSH sandbox has not established its remote backend')
|
||||
const request = {
|
||||
policy,
|
||||
runner: this.facts.argv[0],
|
||||
enforcement: this.facts.enforcement,
|
||||
denialSignatures: this.facts.denialSignatures,
|
||||
}
|
||||
return {
|
||||
argv: [this.node, this.ctx.ssh.helperPath, '--confine', JSON.stringify(request), '--', ...argv],
|
||||
enforcement: this.facts.enforcement,
|
||||
denialSignatures: this.facts.denialSignatures,
|
||||
runnerFailureRules: [...this.facts.runnerFailureRules.map(rule => ({ fatalSignatures: rule.fatalSignatures, ...(rule.allowedExitCodes === undefined ? {} : { allowedExitCodes: rule.allowedExitCodes }), ...(rule.informationalLines === undefined ? {} : { informationalLines: rule.informationalLines }) })), { allowedExitCodes: [127], fatalSignatures: ['dsh-ssh-sandbox: '] }],
|
||||
override async confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
const confined = await this.ctx.ssh.request('sandbox', { argv, policy }, factsSchema, signal)
|
||||
signal?.throwIfAborted()
|
||||
return {
|
||||
...confined,
|
||||
runnerFailureRules: confined.runnerFailureRules.map(rule => ({
|
||||
fatalSignatures: rule.fatalSignatures,
|
||||
...(rule.allowedExitCodes === undefined ? {} : { allowedExitCodes: rule.allowedExitCodes }),
|
||||
...(rule.informationalLines === undefined ? {} : { informationalLines: rule.informationalLines }),
|
||||
})),
|
||||
}
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
throw new SandboxUnavailableError(policy.mode, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** Each confinement request resolves remotely before any subprocess receives its argv. */
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { SandboxUnavailableError, type SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
@@ -11,78 +12,78 @@ const completeFacts = {
|
||||
}
|
||||
|
||||
async function setup(raw: unknown = completeFacts) {
|
||||
const ready = Promise.withResolvers<{ node: string; workspace: string }>()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const dispatch = vi.fn(async (_method: string, _params: unknown) => { entered.resolve(undefined); return raw })
|
||||
const dispatch = vi.fn(async (_method: string, _params: unknown, _signal?: AbortSignal) => raw)
|
||||
class Connection extends Service {
|
||||
readonly helperPath = '/opt/dsh/helper.js'
|
||||
readonly ready = ready.promise
|
||||
constructor(ctx: Context) { super(ctx, 'ssh') }
|
||||
async request<T>(method: string, params: unknown, result: z.ZodType<T>): Promise<T> {
|
||||
return result.parse(await dispatch(method, params))
|
||||
async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal): Promise<T> {
|
||||
return result.parse(await dispatch(method, params, signal))
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
const connection = await ctx.plugin(Connection)
|
||||
const fiber = ctx.plugin(SshSandboxProvider)
|
||||
onTestFinished(async () => {
|
||||
ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
|
||||
await fiber.dispose()
|
||||
await connection.dispose()
|
||||
})
|
||||
return { ctx, fiber, ready, dispatch, entered }
|
||||
const fiber = await ctx.plugin(SshSandboxProvider)
|
||||
onTestFinished(async () => { await fiber.dispose(); await connection.dispose() })
|
||||
return { ctx, dispatch }
|
||||
}
|
||||
|
||||
describe('SSH sandbox provider', () => {
|
||||
it('refuses wrapping before remote backend readiness and then carries verified facts', async () => {
|
||||
it('awaits remote policy resolution and returns the literal enforcing argv', async () => {
|
||||
const state = await setup()
|
||||
expect(() => state.ctx.sandbox.confine(['/usr/bin/node', '-e', '42'], policy)).toThrow(SandboxUnavailableError)
|
||||
expect(state.dispatch).not.toHaveBeenCalled()
|
||||
state.ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
|
||||
await state.fiber
|
||||
expect(state.dispatch).toHaveBeenCalledWith('sandbox', {
|
||||
argv: ['true'], policy: { mode: 'read-only', workspaceRoot: '/remote/work' },
|
||||
})
|
||||
const result = Promise.withResolvers<typeof completeFacts>()
|
||||
state.dispatch.mockReturnValueOnce(result.promise)
|
||||
const argv = ['/usr/bin/node', '-e', 'console.log("shell $() ; quotes")']
|
||||
const confined = state.ctx.sandbox.confine(argv, policy)
|
||||
expect(confined.argv.slice(0, 3)).toEqual(['/usr/bin/node', '/opt/dsh/helper.js', '--confine'])
|
||||
expect(JSON.parse(confined.argv[3]!)).toEqual({
|
||||
policy, runner: '/usr/bin/bwrap', enforcement: 'full', denialSignatures: ['EROFS', 'EACCES'],
|
||||
})
|
||||
expect(confined.argv.slice(4)).toEqual(['--', ...argv])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.denialSignatures).toEqual(['EROFS', 'EACCES'])
|
||||
expect(confined.runnerFailureRules).toEqual([
|
||||
...completeFacts.runnerFailureRules, { allowedExitCodes: [127], fatalSignatures: ['dsh-ssh-sandbox: '] },
|
||||
])
|
||||
expect(state.dispatch).toHaveBeenCalledTimes(1)
|
||||
const controller = new AbortController()
|
||||
let settled = false
|
||||
const pending = state.ctx.sandbox.confine(argv, policy, controller.signal).then((value) => { settled = true; return value })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
expect(state.dispatch).toHaveBeenCalledWith('sandbox', { argv, policy }, controller.signal)
|
||||
const response = { ...completeFacts, argv: ['/usr/bin/bwrap', '--', ...argv] }
|
||||
result.resolve(response)
|
||||
expect(await pending).toEqual(response)
|
||||
})
|
||||
|
||||
it('preserves partial enforcement and omitted runner-rule fields', async () => {
|
||||
const state = await setup({ ...completeFacts, enforcement: 'partial', runnerFailureRules: [{ fatalSignatures: ['runner unavailable'] }] })
|
||||
state.ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
|
||||
await state.fiber
|
||||
const confined = state.ctx.sandbox.confine(['true'], policy)
|
||||
expect(confined.enforcement).toBe('partial')
|
||||
expect(confined.runnerFailureRules[0]).toEqual({ fatalSignatures: ['runner unavailable'] })
|
||||
it('obtains current backend facts for every execution policy', async () => {
|
||||
const state = await setup()
|
||||
expect((await state.ctx.sandbox.confine(['true'], policy)).enforcement).toBe('full')
|
||||
const partial = { ...completeFacts, argv: ['/opt/landlock', '--', 'true'], enforcement: 'partial', runnerFailureRules: [{ fatalSignatures: ['runner unavailable'] }] }
|
||||
state.dispatch.mockResolvedValueOnce(partial)
|
||||
expect(await state.ctx.sandbox.confine(['true'], { ...policy, mode: 'read-only' })).toEqual(partial)
|
||||
expect(state.dispatch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([null, { ...completeFacts, enforcement: 'unknown' }, { ...completeFacts, runnerFailureRules: [{ fatalSignatures: 1 }] }])(
|
||||
'refuses malformed backend observations during service readiness', async (raw) => {
|
||||
it.each([null, { ...completeFacts, argv: [] }, { ...completeFacts, enforcement: 'unknown' }, { ...completeFacts, runnerFailureRules: [{ fatalSignatures: 1 }] }])(
|
||||
'refuses malformed backend observations before exposing argv', async (raw) => {
|
||||
const state = await setup(raw)
|
||||
const rejected = expect(state.fiber).rejects.toThrow()
|
||||
state.ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
|
||||
await rejected
|
||||
expect(() => state.ctx.sandbox.confine(['true'], policy)).toThrow()
|
||||
await expect(state.ctx.sandbox.confine(['true'], policy)).rejects.toBeInstanceOf(SandboxUnavailableError)
|
||||
expect(state.dispatch).toHaveBeenCalledTimes(1)
|
||||
},
|
||||
)
|
||||
|
||||
it('propagates remote backend discovery failure before publishing a wrapper', async () => {
|
||||
it.each([new Error('remote disconnected'), 'remote disconnected'])('reports unavailable confinement without fallback or replay', async (error) => {
|
||||
const state = await setup()
|
||||
state.dispatch.mockRejectedValueOnce(new Error('remote sandbox unavailable'))
|
||||
const rejected = expect(state.fiber).rejects.toThrow('remote sandbox unavailable')
|
||||
state.ready.resolve({ node: '/usr/bin/node', workspace: '/remote/work' })
|
||||
await rejected
|
||||
state.dispatch.mockRejectedValueOnce(error)
|
||||
await expect(state.ctx.sandbox.confine(['true'], policy)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: 'SANDBOX_UNAVAILABLE' })
|
||||
expect(state.dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not send an already-cancelled confinement request', async () => {
|
||||
const state = await setup()
|
||||
const reason = new Error('cancel before confinement')
|
||||
await expect(state.ctx.sandbox.confine(['true'], policy, AbortSignal.abort(reason))).rejects.toBe(reason)
|
||||
expect(state.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves cancellation while remote resolution is pending', async () => {
|
||||
const state = await setup()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel during confinement')
|
||||
state.dispatch.mockImplementationOnce((_method, _params, signal) => new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => { reject(signal.reason) }, { once: true })
|
||||
}))
|
||||
const pending = state.ctx.sandbox.confine(['true'], policy, controller.signal)
|
||||
const rejected = expect(pending).rejects.toBe(reason)
|
||||
controller.abort(reason)
|
||||
await rejected
|
||||
})
|
||||
})
|
||||
@@ -200,7 +200,7 @@ async function confine(): Promise<void> {
|
||||
const { policy } = request
|
||||
if (policy.mode === 'danger-full-access' || process.argv[4] !== '--') throw new Error('Invalid remote sandbox invocation')
|
||||
const workspaceRoot = runtime.ctx.fs.processPath(await runtime.ctx.fs.resolve(policy.workspaceRoot))
|
||||
const wrapped = runtime.ctx.sandbox.confine(process.argv.slice(5), { ...policy, workspaceRoot } as SandboxPolicy)
|
||||
const wrapped = await runtime.ctx.sandbox.confine(process.argv.slice(5), { ...policy, workspaceRoot } as SandboxPolicy)
|
||||
if (wrapped.argv[0] !== request.runner || wrapped.enforcement !== request.enforcement
|
||||
|| JSON.stringify(wrapped.denialSignatures) !== JSON.stringify(request.denialSignatures)) {
|
||||
throw new Error('Remote sandbox backend changed; this connection cannot attest the requested confinement')
|
||||
|
||||
@@ -71,20 +71,21 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', control: 'pipe' },
|
||||
}, preparedSchema)
|
||||
const endpoint = prepared.streams.control!
|
||||
const attack = 'const net=require(\'node:net\');const s=net.createConnection(process.argv[1]);let connected=false;let bytes=0;s.on(\'connect\',()=>{connected=true;const v=Buffer.from(JSON.stringify({type:\'request\',id:\'forged\',method:\'process.start\',params:{}}));const h=Buffer.alloc(4);h.writeUInt32BE(v.length);s.write(Buffer.concat([h,v]))});s.on(\'data\',b=>{bytes+=b.length});s.on(\'error\',()=>{});s.on(\'close\',()=>process.stdout.write(JSON.stringify({connected,bytes})));'
|
||||
const attack = "let connected=false,secure=false;const s=require('node:tls').connect({path:process.argv[1],ciphers:'PSK-AES256-GCM-SHA384',minVersion:'TLSv1.2',maxVersion:'TLSv1.2',pskCallback:()=>({identity:'dsh-stream',psk:Buffer.alloc(32)}),checkServerIdentity:()=>undefined});s.on('connect',()=>{connected=true});s.on('secureConnect',()=>{secure=true});s.on('error',()=>{});s.on('close',()=>process.stdout.write(JSON.stringify({connected,secure})));"
|
||||
const attacker = test.ctx.subprocess.spawn({
|
||||
argv: test.ctx.sandbox.confine([test.hello.node, '-e', attack, endpoint.path], {
|
||||
argv: (await test.ctx.sandbox.confine([test.hello.node, '-e', attack, endpoint.path], {
|
||||
mode: 'read-only', workspaceRoot: test.root,
|
||||
}).argv,
|
||||
})).argv,
|
||||
cwd: test.root, stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 500,
|
||||
})
|
||||
const observed = await processResult(attacker)
|
||||
expect(observed.exitCode).toBe(0)
|
||||
expect(JSON.parse(observed.stdout as string)).toEqual({ connected: true, bytes: 0 })
|
||||
expect(JSON.parse(observed.stdout as string)).toEqual({ connected: true, secure: false })
|
||||
const sockets = await Promise.all(Object.entries(prepared.streams).map(async ([name, value]) =>
|
||||
[name, await test.ctx.ssh.connectStream(value)] as const))
|
||||
const output: Buffer[] = []
|
||||
for (const [name, socket] of sockets) {
|
||||
socket.end()
|
||||
socket.on('data', (chunk) => { if (name === 'stdout') output.push(Buffer.from(chunk)) })
|
||||
socket.resume()
|
||||
}
|
||||
@@ -113,7 +114,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
for await (const chunk of await ctx.fs.streamText(target)) chunks.push(chunk)
|
||||
expect(chunks.join('')).toBe('beta\n')
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: root }
|
||||
const argv = ctx.sandbox.confine(['/bin/bash', '-c', 'cat text.txt; printf "shell\n" > text.txt'], policy).argv
|
||||
const argv = (await ctx.sandbox.confine(['/bin/bash', '-c', 'cat text.txt; printf "shell\n" > text.txt'], policy)).argv
|
||||
const result = await processResult(ctx.subprocess.spawn({
|
||||
argv, cwd: root, stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 500,
|
||||
}))
|
||||
@@ -138,7 +139,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
const test = await setup()
|
||||
try {
|
||||
const code = 'const fs=require(\'node:fs\');const s=new(require(\'node:net\').Socket)({fd:7,readable:true,writable:true,allowHalfOpen:true});let a=[];s.on(\'data\',b=>a.push(b));s.on(\'end\',()=>{let denied;try{fs.writeFileSync(\'forbidden\',\'bad\')}catch(e){denied=e.code}process.stdout.write(JSON.stringify({pid:process.pid,denied,cgroup:process.platform===\'linux\'?fs.readFileSync(\'/proc/self/cgroup\',\'utf8\'):\'\'}));process.stderr.write(\'stderr\');s.end(Buffer.concat(a))});'
|
||||
const argv = test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'read-only', workspaceRoot: test.root }).argv
|
||||
const argv = (await test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'read-only', workspaceRoot: test.root })).argv
|
||||
const handle = test.ctx.subprocess.spawn({
|
||||
argv, cwd: test.root,
|
||||
stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 }, control: 'pipe' }, graceMs: 1000,
|
||||
@@ -166,7 +167,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => {
|
||||
try {
|
||||
const code = 'const s=new(require(\'node:net\').Socket)({fd:7,readable:true,writable:true});const b=Buffer.alloc(65536,120);let n=0;function pump(){while(n<10000){n++;if(!process.stdout.write(b))return}}process.stdout.on(\'drain\',pump);s.on(\'data\',v=>s.write(v));pump();setInterval(()=>{},1000);'
|
||||
handle = test.ctx.subprocess.spawn({
|
||||
argv: test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'workspace-write', workspaceRoot: test.root }).argv,
|
||||
argv: (await test.ctx.sandbox.confine([test.hello.node, '-e', code], { mode: 'workspace-write', workspaceRoot: test.root })).argv,
|
||||
cwd: test.root, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 }, control: 'pipe' }, graceMs: 500,
|
||||
})
|
||||
const reply = once(handle.control!, 'data')
|
||||
|
||||
@@ -32,7 +32,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream pathname replacement',
|
||||
const endpoint = prepared.streams.control!
|
||||
const capture = `${root}/captured-tls`
|
||||
const relay = 'const fs=require(\'node:fs\'),net=require(\'node:net\'),p=process.argv[1];fs.renameSync(p,p+\'.saved\');net.createServer({allowHalfOpen:true},front=>{const back=net.createConnection({path:p+\'.saved\',allowHalfOpen:true});front.on(\'data\',v=>fs.appendFileSync(process.argv[2],v));back.on(\'data\',v=>fs.appendFileSync(process.argv[2],v));front.on(\'error\',()=>back.destroy());back.on(\'error\',()=>front.destroy());front.pipe(back).pipe(front)}).listen(p,()=>process.stdout.write(\'rebound\\n\'));'
|
||||
const confined = ctx.sandbox.confine([process.execPath, '-e', relay, endpoint.path, capture], {
|
||||
const confined = await ctx.sandbox.confine([process.execPath, '-e', relay, endpoint.path, capture], {
|
||||
mode: 'workspace-write', workspaceRoot: root,
|
||||
})
|
||||
attacker = ctx.subprocess.spawn({
|
||||
@@ -48,7 +48,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream pathname replacement',
|
||||
const secured = await authenticateStream(raw, value.capability, 5000)
|
||||
sockets.push(secured)
|
||||
if (name === 'control') control = secured
|
||||
else secured.resume()
|
||||
else { secured.end(); secured.resume() }
|
||||
}
|
||||
const secret = `private-process-payload-${randomUUID()}`
|
||||
const received: Buffer[] = []
|
||||
|
||||
Reference in new issue
Block a user