diff --git a/docs/module-graph.md b/docs/module-graph.md index dddf086bb0..be87d4147d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -445,6 +445,7 @@ flowchart TD pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent @@ -868,6 +869,8 @@ flowchart TD pkg_tool_pwsh --> pkg_bash_env pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools @@ -1232,7 +1235,7 @@ flowchart TD | [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | @@ -1313,7 +1316,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 0c25317faa..625ae29c30 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -30,6 +30,8 @@ "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -46,6 +48,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index a68d5d2e35..e557e56248 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -4,11 +4,13 @@ * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. * - * Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface: - * foreground and `run_in_background` execution (background handles register - * with the generic `ctx.tasks` runtime), the managed `DSH_*` environment - * through the shared `bash-env` registry, and the bash marker/truncation - * rendering story. UI presentation mirrors the bash tool's too: a completed + * Behavior mirrors `dsh-tool-bash` call-for-call minus the escalation + * surface: foreground and `run_in_background` execution (background handles + * register with the generic `ctx.tasks` runtime), the managed `DSH_*` + * environment through the shared `bash-env` registry, the per-call sandbox + * policy resolution (the calling session's mode and cwd travel to the + * confining executor), and the bash marker/truncation rendering story. UI + * presentation mirrors the bash tool's too: a completed * foreground call is a terminal card with the parsed exit-status pill, using * the shared exit-status parse from `@deepseek-ai/dsh-bash`. * @@ -19,12 +21,14 @@ import { isAbsolute, resolve as resolvePath } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' -import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-bash-env' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { parseExitStatus } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' @@ -141,6 +145,14 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const defaultMode = ctx.bash.sandboxMode + const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (defaultMode !== undefined && sandboxPolicy === undefined) { + throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing') + } + /** Resolve the complete standing policy for this call when a confining executor is mounted. */ + const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined => + sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) ctx.systemPrompt.section({ name: 'tool:pwsh', @@ -224,12 +236,15 @@ export function apply(ctx: Context, config: Config = {}): void { /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ async execute(args: PwshToolArgs, exec) { validatePwshArgs(args) + // Description is display metadata; workdir defaults to the caller's session. + const standingPolicy = resolveSandboxPolicy(exec) const workdir = resolveWorkdir(args.workdir, exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, dshEnv: ctx.bashEnv.collect(exec), + ...standingPolicy !== undefined ? { sandboxPolicy: standingPolicy } : {}, } if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 71e3124e7e..454c244c69 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' @@ -24,6 +24,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import type { BashProcessRead } from '@deepseek-ai/dsh-bash' @@ -150,9 +151,59 @@ async function setupWithTasks(toolConfig: Partial = {}, dshHome return { ctx, bash } } +/** + * A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve + * the calling session's standing policy and stamp it on the request, exactly + * like the bash tool — the per-session sandbox-policy regression surface. + */ +class ConfiningFakeBash extends BashExecutor { + requests: BashExecRequest[] = [] + + override get sandboxMode() { + return 'read-only' as const + } + + override resolve(request: BashExecRequest): BashExecSpec { + this.requests.push(request) + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(_spec: BashExecSpec): Promise { + return runResult('ok\n') + } + + override start(_spec: BashExecSpec): BashProcess { + return fakeProcess() + } +} + +/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool. */ +async function setupSandboxed(toolConfig: Partial = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(SandboxPolicyService, {}) + await ctx.plugin(ConfiningFakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as ConfiningFakeBash + return { ctx, bash } +} + /** * Build a fake {@link Agent} with the shared agent/session identity, give it a * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. + * The fake session carries an empty event log (the sandbox-policy resolver + * folds the log for mode overrides, mirroring a real session). */ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const scopeFiber = ctx.plugin(() => {}) @@ -160,7 +211,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const agent = { id, ctx: scopeFiber.ctx, - session: { id, header: { version: 0, id, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 }, events: [] }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -397,6 +448,52 @@ describe('execution through the bash seam', () => { }) }) +describe('per-call sandbox policy resolution', () => { + it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => { + const { ctx, bash } = await setupSandboxed() + const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-')) + const agent = registerFakeAgent(ctx, 'policy-session') + Object.assign(agent.session.header, { cwd: sessionCwd }) + const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent) + expect(result.isError).toBe(false) + // The policy's workspace root is the session cwd canonicalized by the + // policy service (realpath + resolve), NEVER the web server's launch dir; + // the calling session's identity rides along for backend per-session state. + expect(bash.requests[0]?.sandboxPolicy).toEqual({ + mode: 'read-only', + workspaceRoot: resolvePath(realpathSync.native(sessionCwd)), + sessionId: 'policy-session', + }) + }) + + it('falls back to the deployment policy without an agent, and omits the field entirely without a confining executor', async () => { + const { ctx, bash } = await setupSandboxed() + await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(bash.requests[0]?.sandboxPolicy).toEqual({ + mode: 'read-only', + workspaceRoot: resolvePath(realpathSync.native(process.cwd())), + }) + + // The base FakeBash advertises no sandboxMode, so the tool must not stamp + // any policy (the executor defaulting stays the executor's own). + const plain = await setup() + await call(plain.ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(plain.bash.requests[0]).not.toHaveProperty('sandboxPolicy') + }) + + it('fails load when a confining executor has no shared sandbox-policy resolver', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(ConfiningFakeBash) + await expect(ctx.plugin(ToolPwsh)).rejects.toThrow( + 'tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing', + ) + }) +}) + describe('background execution through the task runtime', () => { it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { const { ctx } = await setupWithTasks() diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json index 61b2c69448..2d383d22fa 100644 --- a/packages/bash/tool-pwsh/tsconfig.json +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -38,6 +38,12 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../support/invariants" }