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

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')}`
}