Media executor
The media battery already spawns binaries — ffmpeg, ffprobe, soffice, whatever your engines need. Those spawns went through BinaryExecutor long before this battery existed, and they were unsandboxed because there was nothing to sandbox them with.
sandboxedExecutor closes that without touching the media battery: it quotes cmd + args, asks a BinarySandbox to wrap the invocation, and delegates to inner.exec. Reverting is deleting one wrapper call — inner stays independently usable. A swap, never a one-way door.
This adapter does not stream, and the neighbouring shell tool does
BinaryExecResult returns settled stdout/stderr strings — a shipped contract the media engines already depend on, so this battery does not widen it. This path therefore inherits execa's accumulate-and-maxBuffer behaviour, and deliberately does not raise it: maxBuffer: Infinity trades a bounded failure for an unbounded one.
The streaming path is run_shell_command, which goes through the sandbox's own enforcer contract instead. Two paths, two memory profiles — do not read one section's guarantee onto the other.
Worked adapter
import { sandboxedExecutor } from '@nhtio/adk/batteries/sandbox'
import { execaExecutor } from '@nhtio/adk/batteries/media/engines/execa_executor'
const inner = execaExecutor({ defaultTimeoutMs: 120_000 })
const executor = sandboxedExecutor({
inner,
sandbox: {
async wrap(invocation) {
// Return the invocation the inner executor should receive; do not execute here.
return { ...invocation, command: await srtWrap(invocation.command) }
},
},
bypass: (cmd) => cmd === 'ffprobe',
onSandbox: (event) => audit.log(event),
})
const result = await executor.exec({
cmd: 'ffprobe',
args: ['-v', 'error', 'input.mp4'],
timeoutMs: 10_000,
})
console.log(result.exitCode, result.stdout, result.stderr, result.failed)bypass takes the command only, never the argv, and that signature is the whole design. The model cannot call bypass — but it can influence arguments. So bypassing a binary means trusting it with hostile argv, and the list may hold only binaries that survive that.
Never bypass an interpreter
sh, bash, node, python — anything that turns argv into code. Bypassing one of those does not exempt a command from the sandbox; it hands the model an unsandboxed shell wearing a different name.
And bypass: () => true skips wrapping for everything:
You have just handed the model an unsandboxed shell. That was a choice. Own it.
Every bypass emits a loud event on onSandbox. Keep that sink wired and read it: bypass is audit, not enforcement. It records that you skipped the boundary; it does not make skipping it safe.
The inner execaExecutor accepts an optional execa resolver and otherwise lazily imports execa. It calls execa with reject: false, stripFinalNewline: true, a timeout, and the invocation signal; non-zero status returns in BinaryExecResult. This adapter intentionally never sets maxBuffer: Infinity.
When to use which seam
Use this adapter when an existing media engine already consumes BinaryExecutor and settled text is sufficient. Use createRunShellCommandTool when a model needs one complete spooled artifact with stdout/stderr merged in arrival order and denials woven into the stream. Do not claim the executor adapter is a streaming replacement.
The executor and scratch workspace must share path visibility. A remote executor paired with local workspace paths fails by composition, even if both individual components are correct.
Troubleshooting: what this failure means
| Symptom | Cause |
|---|---|
| Output is truncated or execa rejects on volume | This is settled execa output with bounded retention. Use the streaming shell tool; do not silently raise the cap here. |
| Bypass audit event appears | The command matched bypass; the wrapper was intentionally skipped. Review the allowlist. |
"[object Object]" in model output | A handler returned an object outside the adapter's supported result shapes; return strings, bytes, Media, or the appropriate artifact path. |
dubious ownership | The shell-side SRT policy did not pass gitSafeDirectories through to the child. |
| A turn hangs | An upstream tool gate is waiting for a decider. Every sandbox tool, including reads, must have one. |