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:
@@ -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' }))
|
||||
|
||||
Reference in New Issue
Block a user