P3.13 · D2 + D3 · Process38% of CCA-F24 min build

Agent Skills with Code Execution.

A code-execution Skill with four layers of safety. Layer 1: route file I/O to built-in tools (Read, Write, Edit) and reserve Bash only for actual execution. Layer 2: a PreToolUse hook scans the proposed Bash command for destructive patterns and exits 2 on match. Layer 3: a Docker or Firecracker sandbox runs the code with kernel-level limits (CPU 2, memory 1GB, timeout 30s, network deny). Layer 4: a PostToolUse hook normalizes the raw output to JSON, then a semantic validator confirms the result shape matches the task. The most-tested distractor: Python signal-handler timeouts. The right answer is kernel-level via systemd-run or cgroups; signal handlers can be caught.

Mental modelArchitecture chooses the flow. Deterministic controls enforce policy. Structured state preserves truth.
Loop mascot illustrating Agent Skills with Code Execution.
Share
01 · System & parts

What this system is, and its 5 parts

Think of this as the way you let an agent run actual code (a Python data-analysis script, a one-off shell command, a compiled binary) without giving it the keys to your machine. The script runs inside a sandbox: a small isolated container with strict limits on CPU, memory, time, and network. A PreToolUse hook scans the proposed command BEFORE it runs and refuses anything destructive. After the script runs, a PostToolUse hook normalizes the messy raw output into a clean structured result. A semantic validator confirms the output makes sense given the task before the agent acts on it. The whole point is that code execution is too dangerous to be a free tool; it needs four layers of containment.

5Components
D2Primary domain
8Build steps
9Decision traps
8Concept links
Stack · Claude SDK. Docker or Firecracker for sandboxing. PreToolUse and PostToolUse hooks.Needs · Bash vs built-in tools. Hooks (Pre/Post). cgroup or systemd resource limits.
Agent Skills with Code Execution component architecture.
5 components. Each owns one concept.
01

Bash Tool with Destructive Blocklist

PreToolUse gate, regex-driven

Bash sits behind a PreToolUse hook with a compiled regex blocklist (rm -rf, sudo, drop database, kill -9, chmod 777, curl ... | sh). Match exits 2 with a model-readable stderr message; agent observes the deny as tool_result: is_error: true and re-plans. No prompt-injection bypass: the blocklist is in code, not in the prompt.

Configurationmatcher: 'Bash'. Blocklist regex compiled at hook-load time. Allowlist of safe binaries (kubectl, docker, journalctl, jq, ps, df, top). Exit 2 with stderr.
Concept: hooks
02

Sandbox Runtime (Docker or Firecracker)

fresh sandbox per invocation

Each code-exec invocation runs in a freshly spawned isolated environment. Docker for most cases; Firecracker for stronger isolation when running fully untrusted user code. The image is cached so spin-up stays fast (~500ms warm). The sandbox is destroyed after the run; no state leaks between invocations.

ConfigurationSandbox config: { image: code-exec:latest, cpus: 2, memory_mb: 1024, timeout_sec: 30, network: deny, ipc: private, pid: private }. Image is read-only with a small writable tmpfs scratch space.
Concept: subagents
03

Resource Limit Enforcement

kernel-level via cgroups or systemd

CPU, memory, time, and network limits are enforced at the kernel level (cgroups for Docker, jailer for Firecracker, systemd-run --property=TimeoutStartSec=30s for raw process spawn). Kernel limits cannot be caught or ignored. Python signal-handler-based timeouts are the canonical wrong answer: a busy-loop or a try: pass swallows them.

Configurationcgroup limits: cpu.max=2, memory.max=1G, network deny via iptables egress rule. Timeout via systemd-run --property=TimeoutStartSec=30s. Process exit code 137 (SIGKILL) means OOM; 124 means timeout.
Concept: tool-calling
04

PostToolUse Output Normalizer

raw bytes to structured JSON

Real shell output is messy: mixed Unix timestamps and ISO 8601, mixed status code conventions, multiline stack traces, ANSI color codes. The PostToolUse hook normalizes everything into a stable contract: {status, stdout, stderr, duration_ms, peak_memory_mb, exit_code}. Timestamps converted to ISO 8601 UTC. Color codes stripped. Long stdout truncated.

Configurationmatcher: 'Bash'. Hook reads stdin: {tool_name, tool_input, tool_result, latency_ms, peak_memory_mb}. Returns normalized JSON. Truncates stdout > 4 KB. Strips ANSI codes. Always exits 0.
Concept: structured-outputs
05

Semantic Result Validator

task-aware sanity check

Schema validation guarantees shape; semantic validation guarantees meaning. Given the task context, check that the normalized result is sensible: passed + failed + skipped equals total; counts are non-negative. Failed semantic validation routes the agent back with a specific error message; it does NOT propagate bad data.

ConfigurationPer-task validators registered by Skill. Test-runner validator: passed + failed + skipped total. Data-analysis validator: row count > 0; required columns present.
Concept: evaluation
02 · Problem framing

The problem

What the user needs
  1. Run real Python or shell scripts as part of the agent's workflow, not just simulate them.
  2. Untrusted code stays contained. A misbehaving script does not destroy the host's filesystem or exhaust its memory.
  3. Predictable termination. A runaway loop or infinite recursion stops at exactly the configured time limit.
  4. Consistent output shape. The agent sees a predictable JSON contract regardless of which tool ran or how the script printed.
Why naive approaches fail
  1. Use Bash for everything (including cat file.txt instead of Read). Audit trail is opaque, file I/O and execution conflate.
  2. No PreToolUse blocklist. A clever prompt-injection in the alert text gets rm -rf /prod to execute.
  3. No resource limits. A loop allocates 10 GB or runs forever; the sandbox runner is exhausted.
  4. Heterogeneous raw output passed to the agent. The agent parses inconsistently and routes wrong.
  5. Schema-only validation. The output matches {status: string} but status is 'banana'. The agent acts on nonsense.
Definition of done
  • File I/O routes to Read / Write / Edit. Bash is reserved for actual command execution.
  • PreToolUse hook on Bash with a destructive blocklist (regex). Exit 2 on match.
  • Sandbox runtime (Docker or Firecracker) with kernel-level limits: CPU 2, memory 1GB, timeout 30s, network deny.
  • PostToolUse hook normalizes raw output to JSON: {status, stdout, stderr, duration_ms, peak_memory_mb}.
  • Semantic validator confirms result shape matches the task type.
  • Audit log: every code-exec invocation writes an append-only row.
03 · Data flow

One run, traced end to end

Agent Skills with Code Execution sequence diagram.
Agent Skills with Code Execution end-to-end flow.
04 · Build

8 steps to production

01

Route file I/O to built-in tools; reserve Bash for execution

The first layer of safety is tool selection. cat file.txt should be a Read call, not a Bash call. grep -r foo should be a Grep call. find . -name '*.py' should be a Glob call. Bash is reserved for what the built-ins cannot do: compile code, run tests, execute a Python data-analysis script. This single distinction shrinks the Bash blast-radius by ~80%.

Concept: tool-calling
Python
# Wrong: Bash for everything
# tool_use: Bash, command: "cat config.json"
# tool_use: Bash, command: "grep -r 'TODO' src/"

# Right: built-in tools for I/O; Bash only for execution
# tool_use: Read, file_path: "config.json"
# tool_use: Grep, pattern: "TODO", path: "src/"
# tool_use: Glob, pattern: "**/*.py"

# Bash legitimately for execution:
# tool_use: Bash, command: "pytest tests/ --json-report"
# tool_use: Bash, command: "python analyze.py --input data.csv"

import re
FILE_IO_VIA_BASH = re.compile(
    r"^\s*(cat|head|tail|less|more|grep|find|ls|wc|sort|uniq|cut|awk|sed)\s",
)

def warn_on_io_via_bash(tool_name: str, command: str) -> str | None:
    if tool_name != "Bash":
        return None
    if FILE_IO_VIA_BASH.match(command):
        first = command.strip().split()[0]
        return (
            f"Bash command starts with {first!r}. "
            f"For file I/O prefer Read / Grep / Glob; reserve Bash for execution."
        )
    return None
02

Wire the PreToolUse blocklist hook on Bash

The destructive blocklist runs before the sandbox is even spawned. Compiled regex against rm -rf, sudo, drop database, kill -9, chmod 777, curl | sh. Match exits 2; the agent sees the deny as a tool_result with is_error: true and re-plans.

Concept: hooks
03

Spawn the sandbox with kernel-level limits

Once the blocklist allows the command, the sandbox runs the actual code. Docker is the default; Firecracker for stronger isolation. The sandbox is fresh per invocation, runs read-only with a tmpfs scratch space, and enforces CPU / memory / time / network limits at the kernel level via cgroups.

Concept: subagents
04

Use kernel timeouts, not Python signal handlers

The canonical wrong answer to 'how do I time-out a script after 30 seconds?' is signal.signal(signal.SIGALRM, handler). Python signal handlers can be caught (try: ... except: pass), can be ignored, and do not fire inside C extensions. Use kernel-level timeouts: systemd-run --property=TimeoutStartSec=30s, Docker's intrinsic timeout. Kernel timeouts cannot be caught.

Concept: tool-calling
05

PostToolUse output normalizer

Real shell output is messy. The PostToolUse hook normalizes everything into a stable contract before the agent sees it. Strip ANSI color codes, convert timestamps to ISO 8601 UTC, truncate stdout / stderr above 4 KB.

Concept: structured-outputs
06

Validate the result semantically

Schema validation guarantees shape; semantic validation guarantees meaning. After normalization, run a task-specific validator. For a test runner: passed + failed + skipped total; counts are non-negative. Failed validation returns is_error: true with the specific check that failed.

Concept: evaluation
07

Test resource limits and timeouts adversarially

Ship the sandbox config; then break it on purpose. Run scripts that allocate 10 GB; verify the kernel kills them at 1 GB with exit code 137. Run busy loops; verify timeout at 30s with exit code 124. Run network-egress attempts; verify the deny rule fires.

Concept: evaluation
08

Audit-log every code-exec invocation

Every code-exec invocation writes an append-only row to durable storage: timestamp, command, hook decisions, sandbox metrics (duration, peak memory, exit code), validation outcome, agent that requested it. Retain for at least 90 days.

Concept: evaluation
05 · Right call, wrong call

9 decisions the exam turns into distractors

01 · Reading the contents of `config.json` from agent code
Looks right

Use Bash. command: 'cat config.json'.

Actually right

Use the Read tool. file_path: 'config.json'.

DEC-01
02 · Preventing destructive Bash commands at runtime
Looks right

System prompt instruction: 'never run destructive commands'.

Actually right

PreToolUse hook on Bash with a compiled regex blocklist. Exit 2 on match.

DEC-02
03 · Stopping a runaway script after 30 seconds
Looks right

Python signal handler: signal.signal(signal.SIGALRM, handler).

Actually right

Kernel timeout: systemd-run --property=TimeoutStartSec=30s, or Docker --timeout, or cgroup limit.

DEC-03
04 · Validating that a test-runner result is sane
Looks right

Schema validation only: the JSON has the expected shape.

Actually right

Semantic validation: passed + failed + skipped equals total; counts are non-negative; total > 0 for a non-empty run.

DEC-04
05 · Using Bash for file I/O
Looks right

Agent calls Bash with cat data.json, grep -r foo, find . -name *.py. Audit trail is opaque and the PreToolUse blocklist has to reason about every cat / grep / find.

Actually right

Route file I/O to built-in tools: Read, Grep, Glob. Reserve Bash for actual command execution. The first layer of safety is tool selection.

AP-CODEEXEC-01
06 · No PreToolUse blocklist on Bash
Looks right

A clever prompt-injection in alert text gets rm -rf /prod past the agent. The Bash command runs because no hook scanned it first.

Actually right

PreToolUse hook with a compiled regex blocklist (rm -rf, sudo, drop database, kill -9, chmod 777, curl | sh). Match exits 2 with stderr.

AP-CODEEXEC-02
07 · No resource limits on code execution
Looks right

An agent's Python script allocates 10 GB and crashes the runner. Another runs an infinite loop and starves the queue.

Actually right

Sandbox config with kernel-level limits: CPU 2, memory 1024 MB, timeout 30 s, network deny. Enforced via Docker cgroups, Firecracker jailer, or systemd-run scopes.

AP-CODEEXEC-03
08 · Heterogeneous raw output to the agent
Looks right

Bash output goes straight back: mixed Unix timestamps and ISO 8601, ANSI color codes, multiline stack traces. The agent parses inconsistently.

Actually right

PostToolUse hook normalizes everything: strip ANSI, convert timestamps to ISO 8601 UTC, truncate stdout above 4 KB, emit a stable contract.

AP-CODEEXEC-04
09 · Schema-only validation
Looks right

The result JSON has the expected shape but passed: -5, total: 0. Schema-valid; semantically nonsense. The agent acts on it.

Actually right

Semantic validators registered per task: passed + failed + skipped equals total; counts are non-negative; total > 0 for a non-empty run.

AP-CODEEXEC-05
Agent Skills with Code Execution failure map.
06 · Budget

Cost & latency

~$0.005 to $0.015Per-invocation Claude API

Skill body ~500 tokens system + parameters ~50 tokens + working tokens ~1500-3000 input + ~500 output.

~500 ms warm; ~3 s coldSandbox spin-up overhead

Docker image cached in the runner. Warm spin-up is dominated by container start and cgroup setup.

~3 to 8 seconds end-to-endCode execution duration p95

Sandbox spin-up (500 ms) + actual code (1-5 s) + PostToolUse normalization (50 ms) + semantic validation (20 ms).

~50-200 MB memory, ~100-500 ms CPU per typical invocationSandbox resource usage at the runner level

Most data-analysis or test-runner Skills are short and small. Resource caps prevent the long tail from dominating.

~1 GB per month at 10 K invocationsStorage for audit log

JSONL row ~2-5 KB per invocation. 10 K invocations per month ~30-50 MB. Negligible at object-storage prices.

07 · Ship checklist

Check every gate before release

0/10 checked
  • tool-calling
  • hooks
  • subagents
  • tool-calling
  • structured-outputs
  • evaluation
  • evaluation
08 · Practice

5 exam-pattern questions

Work through one question at a time, check the architecture, then move through the set.

Question 1 of 5 · D2Choose the best answer

A Skill executes Python code. The agent calls Bash with command 'cat data.json'. What is the correct tool for this read?

09 · FAQ

Frequently asked

What languages can a code-execution Skill run?

Anything in the sandbox base image. Common: Python, Node.js, Go, Rust, shell. The image determines available runtimes. For most teams, python:3.12-slim plus a few preinstalled libraries covers 90% of use cases.

Can code access the network?

No by default. --network none denies all egress at the kernel level. Opt in for specific tasks by spawning the sandbox with --network bridge and specific iptables rules.

What happens if code runs out of memory?

The kernel sends SIGKILL when the cgroup memory limit is hit; the process exits with code 137. The harness detects 137 and emits status: oom in the normalized result.

Can code persist state across Skill invocations?

Not by default. Each invocation gets a fresh sandbox with no state from prior runs. Opt-in persistence via a mounted volume on the runner.

How do I validate output semantically?

Per-task validators registered by Skill key. Test-runner validator: passed + failed + skipped total. Data-analysis validator: row count, required columns, aggregate sanity.

Can a Skill call another Skill that does code execution?

Yes. Skill-to-Skill calls are tool calls. Each Skill invocation gets its own sandbox; nesting does not share resources.

What is the timeout for code execution?

30 seconds by default. Configurable per Skill via the sandbox_timeout_sec parameter in the Skill frontmatter. The kernel enforces it; the running code cannot extend or ignore it.

Help someone build it

Share this scenario.

One share is one less team repeating the same architecture mistake.