feat(bash): add stdin + extra env to the executor seam as a trusted-plugin surface

The hooks subsystem runs external hook commands the Claude Code / Codex way:
JSON payload on stdin, context in CLAUDE_PROJECT_DIR / CLAUDE_PLUGIN_ROOT env.
Reusing the ctx.bash seam for that needs two new inputs — but stdin and arbitrary
env are exactly what dsh-bash-local's credential scrub exists to keep away from
model-driven commands. So this adds them as a TRUSTED-PLUGIN surface:

- BashExecRequest + BashExecSpec gain optional `stdin` and `env`. They are plain
  optionals on the resolved spec (not required-but-nullable like `owner`): a
  missing one means "none", the safe default, not a security footgun.
- dsh-bash-local threads them through resolve/run/start. `env` merges AFTER the
  credential scrub, so a trusted caller's explicit entry wins even on a
  credential-shaped name — the scrub guards the harness's OWN ambient creds from
  model-driven commands, not a trusted plugin. stdin is always a pipe, closed
  immediately (with bytes when supplied, empty otherwise — EOF as before); an
  EPIPE from a child that exits without reading is swallowed.
- The model-facing dsh-tool-bash NEVER forwards model input into stdin/env (its
  request is command/workdir/timeoutMs/signal/owner only). A regression guard
  drives the real tool with adversarial args and asserts the request carries
  neither field — proven to go red if the consumer ever forwards them.

Configurable scrub (in an earlier sketch) is dropped as speculative: the explicit
`env` field already gives a trusted caller full control, and no caller needs to
broaden the ambient scrub. Documented in a new architecture RFC, the bash.md
type-equiv blocks, and the three bash READMEs.
This commit is contained in:
Tianyi Cui
2026-06-30 13:52:25 +08:00
parent b8d0da9f8c
commit 13c6e847a2
12 changed files with 328 additions and 7 deletions

View File

@@ -116,6 +116,10 @@ export class LocalBashExecutor extends BashExecutor {
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
timeoutMs,
...request.signal ? { signal: request.signal } : {},
// Carry the trusted-plugin stdin/env through verbatim — optional, no
// config default (absent means none). env merges AFTER the scrub in run.ts.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
// Carry the owner through verbatim (required-but-nullable on the spec):
// the executor never interprets it — the consumer's access policy does.
owner: request.owner,
@@ -129,6 +133,8 @@ export class LocalBashExecutor extends BashExecutor {
timeoutMs: spec.timeoutMs,
maxOutputBytes: this.config.maxOutputBytes,
signal: spec.signal,
stdin: spec.stdin,
env: spec.env,
}, this.internals).done
return { ...outcome, timeoutMs: spec.timeoutMs }
}
@@ -145,6 +151,8 @@ export class LocalBashExecutor extends BashExecutor {
timeoutMs: 0,
maxOutputBytes: this.config.maxOutputBytes,
signal: spec.signal,
stdin: spec.stdin,
env: spec.env,
}, this.internals)
const id = BashTaskId(`bash-${this.nextTaskId++}`)

View File

@@ -42,13 +42,24 @@ export const ENV_OVERRIDES = {
*/
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/** process.env minus credential-shaped vars, plus the model-friendly overrides. */
export function childEnv(): NodeJS.ProcessEnv {
/**
* `process.env` minus credential-shaped vars, plus the model-friendly
* overrides, plus any caller-supplied `extra` entries.
*
* Layering matters: the scrub drops `process.env` credentials, then
* `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is
* merged LAST so a TRUSTED-PLUGIN entry wins even when its name matches the
* scrub pattern (the scrub guards against leaking the HARNESS's ambient
* credentials into model-driven commands; an in-process plugin that explicitly
* sets a var has taken responsibility for it). `extra` is NEVER model-supplied
* — `dsh-tool-bash` does not forward model input here (see its module doc).
*/
export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
}
return { ...env, ...ENV_OVERRIDES }
return { ...env, ...ENV_OVERRIDES, ...extra }
}
/** What to run and under which limits (resolved — no defaults in here). */
@@ -61,6 +72,18 @@ export interface SpawnSpec {
maxOutputBytes: number
/** Abort signal — kills the process group when fired. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the child's stdin, then close it. Absent (or empty)
* leaves stdin closed/empty. A TRUSTED-PLUGIN surface (see {@link SpawnSpec}'s
* consumer `dsh-bash`); never carries model input.
*/
stdin?: string | undefined
/**
* Extra environment entries, merged onto the scrubbed env AFTER the
* credential scrub and the model-friendly overrides (so an explicit entry
* wins). A TRUSTED-PLUGIN surface; never carries model input.
*/
env?: Record<string, string> | undefined
}
/** Raw outcome of one closed process (before result shaping). */
@@ -272,13 +295,24 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
}
// stdin is ALWAYS a pipe (kept literal so the typed spawn overload guarantees
// non-null stdout/stderr) and is closed immediately: with bytes when a
// trusted plugin supplied stdin, empty otherwise. A closed empty pipe gives a
// reading child EOF exactly as `/dev/null` would, so the no-stdin path (every
// model-driven call) is unchanged.
const child = spawn('bash', ['-c', spec.command], {
cwd: spec.cwd,
env: childEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
env: childEnv(spec.env),
stdio: ['pipe', 'pipe', 'pipe'],
detached: true,
})
// A child that exits without reading stdin makes the write error EPIPE —
// swallow it (the command's outcome rides on its exit code/output, not the
// stdin write) so it never crashes the host or rejects `done`.
child.stdin.on('error', () => { /* EPIPE: child closed stdin early; outcome rides on exit. */ })
child.stdin.end(spec.stdin ?? '')
const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })