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

@@ -21,7 +21,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
- **Model-friendly env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results.
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's **trusted-plugin** `env` is merged LAST (after the scrub), so an in-process plugin's explicit entry wins even on a credential-shaped name — the scrub guards the harness's *ambient* credentials from *model-driven* commands, not a trusted caller. The spec's `stdin` (also trusted-plugin) is written to the child and closed; with none supplied, stdin is an immediately-closed empty pipe (EOF, as before). See [the trusted-plugin RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
- **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
## Sandboxing

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

View File

@@ -106,6 +106,23 @@ describe('LocalBashExecutor.run', () => {
const { bash } = await setup()
await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
})
it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => {
const { bash } = await setup()
const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } })
// resolve() keeps the trusted-plugin fields verbatim (optional, no default).
expect(spec.stdin).toBe('piped\n')
expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' })
const result = await bash.run(spec)
expect(result.stdout.text).toBe('piped\n[env-ok]\n')
})
it('resolve() omits stdin/env when the request supplies neither', async () => {
const { bash } = await setup()
const spec = bash.resolve({ command: 'true' })
expect('stdin' in spec).toBe(false)
expect('env' in spec).toBe(false)
})
})
describe('LocalBashExecutor background tasks', () => {
@@ -131,6 +148,19 @@ describe('LocalBashExecutor background tasks', () => {
await Promise.all([first.done, second.done])
})
it('threads stdin and extra env into a background task', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({
command: 'cat; echo "[$DSH_BG_VAR]"',
stdin: 'bg-stdin\n',
env: { DSH_BG_VAR: 'bg-env' },
}))
const read = await readUntil(bash, task.id, '[bg-env]')
expect(read.delta).toContain('bg-stdin')
await task.done
expect(task.exitCode).toBe(0)
})
it('readOutput returns increments without re-delivery', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))

View File

@@ -158,6 +158,49 @@ describe('runBash', () => {
})
})
describe('stdin and extra env (trusted-plugin surface)', () => {
it('writes stdin to the command and closes it', async () => {
const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('hello from stdin\n')
})
it('a command that reads stdin sees EOF when none is supplied', async () => {
// No stdin → the always-piped-but-empty stdin closes immediately, so `cat`
// reads EOF and exits 0 with no output (it does NOT block).
const result = await runBash(spec('cat')).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('')
})
it('merges extra env entries onto the scrubbed environment', async () => {
const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },
})).done
expect(result.stdout.text).toBe('alpha/beta\n')
})
it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
// TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
// DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
// entry is still honored — the scrub only drops AMBIENT process.env creds.
const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', {
env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' },
})).done
expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
})
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
// The child exits immediately without reading; closing our end of a stdin
// pipe still holding ~1MiB triggers EPIPE on the write. The handler must
// swallow it: `done` resolves normally with the child's real exit.
const big = 'x'.repeat(1024 * 1024)
const result = await runBash(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)
expect(result.aborted).toBe(false)
})
})
describe('output truncation and spill', () => {
it('keeps the tail and spills the full stream to disk', async () => {
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.

View File

@@ -28,4 +28,6 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
## Vocabulary
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
`stdin` and `env` are a **trusted-plugin surface**: an in-process plugin (the hooks bridges, native plugins) sets them to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool deliberately never forwards model input into either — so a model cannot smuggle an env var or stdin payload past the implementation's credential scrub. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default, not a security footgun. See [the trusted-plugin RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).

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

View File

@@ -40,6 +40,10 @@ These tools own how their calls render in a UI (an editor's tool-call card) via
When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get``onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.
## Trusted-plugin boundary: env / stdin are never model-driven
The `BashExecRequest` seam carries optional `stdin` and `env` (a **trusted-plugin surface** used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env). This tool deliberately **never** threads model input into either: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored — it cannot smuggle an environment variable or stdin payload past `dsh-bash-local`'s credential scrub. A regression guard (the "trusted-plugin boundary" tests) drives the real tool with adversarial args and asserts the resulting request carries neither field. See [the trusted-plugin RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Permissions
`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/execute` waterfall (veto or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.

View File

@@ -863,3 +863,99 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
})
})
describe('trusted-plugin boundary: the model-facing bash tool never sets env/stdin', () => {
/**
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
* test can assert what the model-facing tool DID and DID NOT forward. `stdin`
* and `env` are a TRUSTED-PLUGIN surface (in-process plugins only); the `bash`
* tool must never thread model-supplied input into them, even when the model
* smuggles extra keys into the tool arguments. Foreground `run()` returns a
* canned result; `start()` is unused here.
*/
class RecordingBashExecutor extends BashExecutor {
readonly requests: BashExecRequest[] = []
resolve(request: BashExecRequest): BashExecSpec {
this.requests.push(request)
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 0,
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
owner: request.owner,
}
}
run(): Promise<BashRunResult> {
return Promise.resolve({
exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 0,
stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false },
})
}
start(): BashTask { throw new Error('unused') }
get(): BashTask | undefined { return undefined }
ownerOf(): OwnerToken | undefined { return undefined }
list(): BashTask[] { return [] }
readOutput(): BashTaskRead { throw new Error('unused') }
kill(): boolean { return false }
}
async function setupRecording() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(RecordingBashExecutor)
await ctx.plugin(ToolBash)
return { ctx, bash: ctx.bash as RecordingBashExecutor }
}
it('does not forward env/stdin even when the model smuggles them as extra arguments', async () => {
const { ctx, bash } = await setupRecording()
// Adversarial args: the model includes `env` and `stdin` keys (and a
// credential-shaped value) hoping they reach the executor. The bash tool's
// schema ignores unknown keys, and execute() builds the request from only
// command/workdir/timeoutMs/signal — so the recorded request carries NEITHER.
await ctx.tools.execute({
callId: CallId('boundary-1'),
name: 'bash',
arguments: {
command: 'echo hi',
description: 'echo',
env: { SNEAKY_API_KEY: 'leak' },
stdin: 'malicious payload',
},
})
expect(bash.requests).toHaveLength(1)
const request = bash.requests[0]!
expect(request.command).toBe('echo hi')
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
})
it('a background bash call likewise carries no env/stdin', async () => {
const { ctx, bash } = await setupRecording()
// start() throws in this recorder, but resolve() runs first and records the
// request — which is all this boundary assertion needs.
await ctx.tools.execute({
callId: CallId('boundary-2'),
name: 'bash',
arguments: {
command: 'sleep 1',
description: 'sleep',
run_in_background: true,
env: { TOKEN: 'leak' },
stdin: 'x',
},
})
expect(bash.requests).toHaveLength(1)
const request = bash.requests[0]!
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
// The owner token IS set on a background call (the isolation fence) — proving
// the recorder sees the real request the consumer built, so the absent
// env/stdin above is a real negative, not a recorder that drops everything.
expect('owner' in request).toBe(true)
})
})