name: container-interactivity
description: Real-time data streaming to/from isolated containers for AI agent sandboxing. Docker ephemeral exec patterns, stdin/stdout pipe attachment, nsjail namespace jail setup, streaming output with live tail, container health monitoring, and clean teardown. Sources: moby/moby, google/nsjail, containers/podman, kata-containers/kata-containers, firecracker-microvm/firecracker.
origin: yana-ai — synthesized from moby/moby, google/nsjail, containers/podman, kata-containers/kata-containers, firecracker-microvm/firecracker
license: Apache-2.0
version: 1.0.0
compatibility: yana-ai >= 1.3.47
/container-interactivity
When to Use
- Agent tool call must run in complete filesystem/network isolation
- Streaming large output from a sandboxed process (no buffering in memory)
- Attaching to a running container stdin to feed input interactively
- Post-exec cleanup: confirm container is fully destroyed, no state leaks
Do NOT use for
- Purely in-process operations (no exec boundary — no container needed)
- Dev-only scripts where ulimit fallback is acceptable
Decision: Docker vs nsjail vs ulimit
Host has Docker daemon + image already built?
YES → Docker (strongest isolation, read-only FS, --network=none)
NO →
Linux host with root/CAP_SYS_ADMIN?
YES → nsjail (Linux namespaces, ~1ms overhead, no daemon)
NO → ulimit fallback (resource limits only, no FS isolation)
Hardware-level isolation required (multi-tenant cloud)?
YES → Firecracker micro-VM via sandbox-exec.sh
Docker: Ephemeral Isolated Execution
docker run --rm \
--name "yamtam-$(uuidgen | head -c 8)" \
--network none \
--read-only \
--tmpfs /workspace:rw,size=64m,noexec \
--tmpfs /tmp:rw,size=32m,noexec \
--memory 128m \
--memory-swap 128m \
--cpus 0.5 \
--pids-limit 64 \
--cap-drop ALL \
--security-opt no-new-privileges \
--user nobody \
yamtam-sandbox:latest \
bash -c "$TOOL_COMMAND"
import { execa } from 'execa'
async function runInContainer(command: string, args: string[]): Promise<string> {
const containerName = `yamtam-${Date.now()}`
const output: string[] = []
const proc = execa('docker', [
'run', '--rm',
'--name', containerName,
'--network', 'none',
'--read-only',
'--tmpfs', '/workspace:rw,size=64m,noexec',
'--memory', '128m',
'--cpus', '0.5',
'--pids-limit', '64',
'--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges',
'--user', 'nobody',
'yamtam-sandbox:latest',
command, ...args,
])
proc.stdout?.on('data', (chunk: Buffer) => {
const line = chunk.toString()
output.push(line)
process.stdout.write(`[sandbox] ${line}`)
})
proc.stderr?.on('data', (chunk: Buffer) => {
process.stderr.write(`[sandbox:err] ${chunk}`)
})
const { exitCode } = await proc
if (exitCode === 124) throw new Error('sandbox timeout exceeded')
if (exitCode !== 0) throw Object.assign(new Error('sandbox command failed'), { exitCode })
return output.join('')
}
Attach stdin to Running Container
import { spawn } from 'child_process'
function attachContainerStdin(containerId: string, input: string): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn('docker', ['exec', '-i', containerId, 'sh'], {
stdio: ['pipe', 'pipe', 'pipe'],
})
const output: string[] = []
proc.stdout.on('data', (d: Buffer) => output.push(d.toString()))
proc.stderr.on('data', (d: Buffer) => output.push(d.toString()))
proc.stdin.write(input + '\n')
proc.stdin.()
proc.(, {
(code === ) (output.())
( ())
})
})
}
nsjail: Namespace Jail (no Docker daemon)
nsjail \
--mode o \
--time_limit 30 \
--rlimit_as 131072 \
--rlimit_cpu 30 \
--rlimit_fsize 65536 \
--rlimit_nofile 32 \
--disable_proc \
--iface_no_lo \
--user nobody \
--group nobody \
--chroot / \
--bindmount_ro /usr \
--bindmount_ro /bin \
--bindmount_ro /lib \
--tmpfsmount /tmp \
--cwd /tmp \
-- bash -c "$TOOL_COMMAND"
Container Health & Teardown
async function ensureContainerGone(name: string): Promise<void> {
try {
await execa('docker', ['kill', name]).catch(() => {})
await execa('docker', ['rm', '-f', name]).catch(() => {})
const { stdout } = await execa('docker', ['ps', '-a', '--filter', `name=${name}`, '--format', '{{.ID}}'])
if (stdout.trim()) {
throw new Error(`container ${name} still exists after teardown`)
}
} catch (e) {
console.error(`[sandbox] teardown warning: ${(e as Error).message}`)
}
}
(): <[]> {
{ stdout } = (, [, , , , , ])
stdout.().().()
}
Output Size Cap in Streaming Context
class SandboxOutputStream extends Transform {
#bytes = 0
readonly #cap = 16 * 1024
_transform(chunk: Buffer, _enc: string, cb: (err: Error | null, data?: Buffer) => void) {
this.#bytes += chunk.length
if (this.#bytes > this.#cap) {
this.push(Buffer.from('\n[SANDBOX OUTPUT TRUNCATED — 16KB cap]\n'))
this.end()
cb(null)
return
}
cb(null, chunk)
}
}
proc.stdout?.pipe(new SandboxOutputStream()).pipe(process.stdout)
Anti-Fake-Pass Checklist
❌ Container runs as root (--user missing or --user root)
❌ --network flag omitted (agent can make outbound calls)
❌ --rm missing (containers accumulate, disk fills, state leaks)
❌ stdin left open after write (process hangs waiting for EOF)
❌ -it flag used in non-TTY context (CI/agent pipelines fail with "not a TTY")
❌ Output buffered in memory before returning (OOM on large tool results)
❌ nsjail used without --disable_proc (process memory injection possible)
❌ Container teardown only in happy path (must be in finally block)
❌ Dangling volumes not audited (sensitive data persists on host)