feat(pwsh): mirror the bash sandbox surface — denial rendering, escalation, and the ConstrainedLanguage contract

tool-pwsh now renders sandbox denial facts (denial marker + same-turn escalation hint, runner-failed notice on background reads) and advertises the sandbox_permissions/justification escalation pair resolved through ctx.approval before execution — the shared fail-closed sequence from dsh-sandbox, with the tool name 'pwsh'. The description teaches the ConstrainedLanguage contract under the Windows sandbox (both confined modes run pwsh in CLM: Add-Type, non-core .NET statics, COM, and reflection fail; the mode cannot be lifted — probe-verified, pinned by LANGMODE assertions in the runner suite and description assertions in the tool suite). The stale pwsh-sandbox JSDoc ('the tool owns approval') and the parity notes' superseded 'minus sandbox' claims are corrected.
This commit is contained in:
Huanqi Cao
2026-08-08 20:11:21 +08:00
parent 2dd9af6f4b
commit 83f835e4cb
18 changed files with 471 additions and 82 deletions
+120 -20
View File
@@ -4,15 +4,17 @@
* `@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 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`.
* Behavior mirrors `dsh-tool-bash` call-for-call: 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), the
* sandbox-denial rendering with the same-turn escalation surface
* (`sandbox_permissions` + `justification` resolved through
* `ctx.approval`), 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`.
*
* @module @deepseek-ai/dsh-tool-pwsh
*/
@@ -27,12 +29,15 @@ 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 {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } 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'
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
import type { RenderablePwshResult } from './render.ts'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
@@ -61,6 +66,8 @@ interface PwshToolArgs {
timeoutMs?: number
workdir?: string
run_in_background?: boolean
sandbox_permissions?: string
justification?: string
}
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
@@ -73,6 +80,7 @@ interface PwshForegroundResult {
timeoutMs: number
stdout: { text: string; truncated: boolean; spillPath?: string }
stderr: { text: string; truncated: boolean; spillPath?: string }
sandbox?: { mode: string; denied: boolean; enforcement?: string; runnerFailed?: boolean }
}
/* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */
@@ -86,21 +94,41 @@ function validatePwshArgs(args: PwshToolArgs): void {
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
}
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
// the shared rule both enforcing families validate identically.
validateEscalationArgs(args.sandbox_permissions, args.justification)
}
/* jscpd:ignore-end */
function pwshDescription(backgroundEnabled: boolean): string {
function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
const background = backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
: 'Background execution is not available; long-running commands must finish within the timeout.'
return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
const base = 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
+ 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — '
+ 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment '
+ 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. '
+ 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. '
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
+ background
if (escalationModes.length === 0) return base
return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and '
+ 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
+ '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail '
+ 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. '
+ 'Attempting a command the sandbox may deny is safe and expected: run it and read the '
+ 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
+ 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry '
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
+ 'approval prompt raised by that retry is how the user consents. If the session states approval '
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
+ 'Never escalate speculatively: ground the request in a real denial — normally the one this command '
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
+ 'A rejected escalation is final for that command — stop and explain, never work around '
+ 'it — but it does not forbid attempting or escalating other commands later.'
}
/**
@@ -133,6 +161,14 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
/* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */
stdout: output(result.stdout),
stderr: output(result.stderr),
...result.sandbox !== undefined ? {
sandbox: {
mode: result.sandbox.mode,
denied: result.sandbox.denied,
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
},
} : {},
}
}
@@ -143,17 +179,56 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
} as const
/* jscpd:ignore-end */
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */
export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
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')
}
/* jscpd:ignore-end */
/** 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 })
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's escalation resolver (pwsh-tool-and-executor Agent Note). */
/**
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
* anything executes, delegating the shared fail-closed sequence (strict
* widening, channel resolution, outcome mapping) to
* {@link approveEscalation}. This tool contributes only the composition
* guard (the fields are unadvertised without a sandboxing executor, yet
* schema validation checks advertised keys only, so an unadvertised
* `sandbox_permissions` still reaches execute) and the approval
* ingredients. The shared policy resolver is required whenever the
* executor advertises confinement, so a split composition fails at
* tool-plugin load.
*/
const approvePwshEscalation = (
mode: string,
justification: string,
exec: ToolExecution,
standingPolicy: SandboxExecutionPolicy | undefined,
): Promise<SandboxMode> => {
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode
return approveEscalation(
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
{
approver: ctx.get('approval'),
agent: exec.agent,
callId: exec.callId,
toolName: 'pwsh',
signal: exec.signal,
},
)
}
/* jscpd:ignore-end */
ctx.systemPrompt.section({
name: 'tool:pwsh',
order: 105,
@@ -163,7 +238,8 @@ export function apply(ctx: Context, config: Config = {}): void {
ctx.tools.register(defineTool({
name: 'pwsh',
description: pwshDescription(backgroundEnabled),
description: pwshDescription(backgroundEnabled, escalationModes),
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's parameter surface (pwsh-tool-and-executor Agent Note). */
parameters: {
command: { type: 'string', required: true, description: 'The PowerShell command to execute.' },
description: {
@@ -178,7 +254,19 @@ export function apply(ctx: Context, config: Config = {}): void {
...backgroundEnabled ? {
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
} : {},
...escalationModes.length > 0 ? {
sandbox_permissions: {
type: 'string' as const,
enum: [...escalationModes],
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
},
justification: {
type: 'string' as const,
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
},
} : {},
},
/* jscpd:ignore-end */
output: {
// The foreground result wire shape mirrors dsh-tool-bash's by contract —
// consumers of one must accept the other (see the pwsh-tool-and-executor
@@ -221,6 +309,16 @@ export function apply(ctx: Context, config: Config = {}): void {
spillPath: { type: 'string' },
},
},
sandbox: {
type: 'object',
additionalProperties: false,
properties: {
mode: { type: 'string', required: true },
denied: { type: 'boolean', required: true },
enforcement: { type: 'string' },
runnerFailed: { type: 'boolean' },
},
},
},
},
],
@@ -230,7 +328,7 @@ export function apply(ctx: Context, config: Config = {}): void {
type: 'text',
text: value.kind === 'background'
? `started background task ${value.taskId}`
: renderPwshResult(value),
: renderPwshResult(value as RenderablePwshResult, escalationModes),
}],
},
/* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */
@@ -238,13 +336,19 @@ export function apply(ctx: Context, config: Config = {}): void {
validatePwshArgs(args)
// Description is display metadata; workdir defaults to the caller's session.
const standingPolicy = resolveSandboxPolicy(exec)
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
: undefined
const policy = approvedMode === undefined
? standingPolicy
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
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 } : {},
...policy !== undefined ? { sandboxPolicy: policy } : {},
}
if (args.run_in_background === true) {
// Undeclared keys are allowed, so schema omission also needs enforcement.
@@ -256,15 +360,11 @@ export function apply(ctx: Context, config: Config = {}): void {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// The caller owns cancellation until ctx.tasks commits detached ownership.
/* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort;
pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts
already-aborted signals first, so this mirror-only guard has no reachable trigger. */
if (exec.signal.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
throw error
}
/* v8 ignore end */
// Task preflight finishes before the starter can spawn a process.
const id = tasks.start({
kind: 'pwsh',
@@ -275,7 +375,7 @@ export function apply(ctx: Context, config: Config = {}): void {
return {
cancel: () => void proc.kill(),
done: proc.done.then(() => processOutcome(proc)),
readOutput: () => renderPwshProcessRead(proc.readOutput()),
readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
}
},
})
+42 -10
View File
@@ -1,17 +1,20 @@
/**
* Model-facing result rendering for the pwsh tool — the PowerShell twin of
* `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked
* stderr section, truncation notices with spill paths, then exit-status
* markers. Non-zero exits are reported, not errored — the model decides how to
* react; only infrastructure failures (spawn errors, aborts) surface as
* isError results.
* `dsh-tool-bash`'s renderer: stdout, a marked stderr section, sandbox
* denial/runner-failure markers (with the same-turn escalation hint), and
* truncation notices with spill paths, then exit-status markers. Non-zero
* exits are reported, not errored — the model decides how to react; only
* infrastructure failures (spawn errors, aborts) surface as isError
* results.
*
* @module @deepseek-ai/dsh-tool-pwsh/render
*/
import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { BashProcessRead, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts (Agent Note). */
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
@@ -27,6 +30,7 @@ export interface RenderablePwshResult {
timeoutMs: number
stdout: CollectedOutput
stderr: CollectedOutput
sandbox?: BashSandboxInfo
}
/**
@@ -34,9 +38,15 @@ export interface RenderablePwshResult {
* stderr section, then exit-status markers, matching the bash tool's story —
* a clean exit (0, no signal) produces no marker.
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises;
* non-empty adds the same-turn escalation hint after a denial marker
* (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
*/
export function renderPwshResult(result: RenderablePwshResult): string {
export function renderPwshResult(
result: RenderablePwshResult,
escalationModes: readonly SandboxMode[] = [],
): string {
const out = streamText(result.stdout)
const err = streamText(result.stderr)
@@ -49,6 +59,14 @@ export function renderPwshResult(result: RenderablePwshResult): string {
if (body.length === 0) body = '(no output)'
const markers: string[] = []
// Keep the exit marker last because parseExitStatus anchors there.
if (result.sandbox?.denied) {
markers.push(sandboxDenialMarker(result.sandbox.mode))
// Hint only when the composition exposes escalation, before the final exit marker.
if (escalationModes.length > 0) {
markers.push(escalationHintMarker('command'))
}
}
// A command may trap the termination and exit 0 after timeout; still report interruption.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
if (result.signal !== null) {
@@ -67,14 +85,28 @@ export function renderPwshResult(result: RenderablePwshResult): string {
* sees: the incremental delta, plus the lossy-read notice (with full-stream
* spill paths) when in-memory truncation dropped unread bytes.
* @param read - one incremental read from the process handle.
* @returns the delta text with any loss notice appended.
* @param sandbox - settled sandbox facts, when this was a confined process.
* @param escalationModes - escalation targets advertised by this composition.
* @returns the delta text with any loss or sandbox notice appended.
*/
export function renderPwshProcessRead(read: BashProcessRead): string {
export function renderPwshProcessRead(
read: BashProcessRead,
sandbox?: BashSandboxInfo,
escalationModes: readonly SandboxMode[] = [],
): string {
const notices: string[] = []
if (read.lossy) {
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
}
if (sandbox?.runnerFailed) {
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
} else if (sandbox?.denied) {
notices.push(sandboxDenialMarker(sandbox.mode))
if (escalationModes.length > 0) {
notices.push(escalationHintMarker('command'))
}
}
if (notices.length === 0) return read.delta
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
}