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 files changed
+328 -7

No files matched your search

+34
View File
@@ -45,6 +45,23 @@ export interface BashExecRequest {
timeoutMs?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin, then close it. Absent leaves stdin
* closed/empty (the default for model-driven tool calls). A TRUSTED-PLUGIN
* surface: the model-facing bash tool does NOT thread model-supplied input
* here — it is set by in-process plugins (e.g. the hooks bridges, which write
* a hook command's JSON payload to its stdin).
*/
stdin?: string | undefined
/**
* Extra environment entries for the command, merged AFTER the
* implementation's credential scrub (so an explicit entry here is honored even
* when its name matches the scrub pattern — the caller takes responsibility).
* Like {@link stdin}, a TRUSTED-PLUGIN surface: the model-facing bash tool
* never forwards model-supplied env; in-process plugins (the hooks bridges)
* set hook env vars (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …) here.
*/
env?: Record<string, string> | undefined
/**
* Opaque OWNER token for a background task — the consumer's isolation key
* (the tool layer passes the owning agent's `session.header.id`). The
@@ -70,6 +87,23 @@ export interface BashExecSpec {
timeoutMs: number
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin (then close it), carried through
* verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec
* (unlike `owner`): it has no config default, so a missing one means "no
* stdin" — the safe, ordinary case — not a silent footgun, so it stays a
* plain optional rather than required-but-nullable. A TRUSTED-PLUGIN surface
* (see the request field).
*/
stdin?: string | undefined
/**
* Extra environment entries, carried through verbatim from
* {@link BashExecRequest.env} and merged by the implementation AFTER its
* credential scrub (an explicit entry wins even when its name matches the
* scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no
* config default, absent means "no extra env". A TRUSTED-PLUGIN surface.
*/
env?: Record<string, string> | undefined
/**
* Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
* being required on the resolved spec): {@link BashExecutor.resolve} carries