A4 · Advanced Security

A4 · 30 min

Advanced Security

Six months after you shipped the Meridian lead-triage worker from lesson 5.7, a teammate installs a community MCP server that "enriches leads with firmographic data." It works beautifully in the demo. What nobody read: one of its tools returns a notes field the model treats as instructions, and the server phones a domain you never approved. Your layered defense from lesson 3.8 assumed you knew every input surface. You just added one you never audited, running with your agent's trust, and the first sign of trouble will be a support ticket, not an alert. This lesson is the difference between that call being a shrug and a breach.

Lesson 3.8 gave you the doctrine: every input is untrusted, permissions bound actions, the boundary moves to the target, and you stack Layer 1 (the enforced boundary), Layer 2 (the untrusted-data policy in the prompt), and Layer 3 (the independent output gate). This appendix does not re-teach that. It takes you from "I defended one agent" to "I run agents in production": deeper threat modeling, real sandboxing architectures, an incident-response runbook, and how to audit a third-party server before it earns your trust.

Threat modeling that fits on one page

Ad hoc defense stops the attack you imagined. A threat model stops the class of attacks, and it does that by forcing you to write down three lists before you write any hook. Do this per agent, not per company. It takes twenty minutes and it is the highest-leverage security artifact you will produce.

Assets: what an attacker wants. Not "the system." The specific things: the .env secrets, the client list and retainers Meridian guards, the production database, the ability to send email as you, the ability to push to main. Rank them. A leaked marketing draft is a bad day; a leaked customer table is a disclosure obligation. Your defenses should be proportional to what sits at the top of this list.

Capabilities: what the agent can actually do. Enumerate every tool, every allowedTools entry, every MCP server, every hook that can act, and the exact scope of each. This is where the surprises live. The changelog agent in lesson 3.8 had Read unscoped and nobody noticed until the payload landed. Write the capabilities down and the dangerous ones announce themselves: an unscoped read, a Bash that is not pinned to specific commands, a send-email tool, any network egress.

Untrusted inputs: where the adversary's words enter. Every channel that carries content the agent reads: commit messages, file contents, fetched pages, tool results from MCP servers, inbound emails, webhook bodies, filenames, a notes field on an enriched lead. Anthropic's guidance splits injection into the two models you learned in 3.8, direct (your user is the adversary) and indirect (trusted user, third-party content carries the payload), and your input list should tag each channel as one or the other.

The model is the crossing of the three lists: for each untrusted input, ask which capability it could steer, and which asset that reaches. The changelog attack is one row in that table: input git log, capability unscoped Read, asset .env. Every row where a high-value asset is reachable from an untrusted input through a granted capability is a defense you owe. Rows where the capability cannot reach the asset are already safe, and naming them stops you from gold-plating.

Rendering diagram...

Sandboxing architectures: the boundary the model cannot argue with

Layer 1 in lesson 3.8 was permission scope plus a fail-closed hook, enforced in the harness outside the model. Sandboxing is Layer 1 taken down to the operating system, where an injected instruction has nothing to talk to. Claude Code gives you a ladder of isolation, and you pick the rung by threat model, not by reflex.

The sandboxed Bash tool (/sandbox) isolates Bash commands and their child processes. On macOS it uses the built-in Seatbelt framework; on Linux and WSL2 it uses bubblewrap. By default, sandboxed commands write only to the working directory and the session temp directory, can read more widely, and the first time a command reaches a new network domain, Claude Code prompts. The payoff is fewer prompts (auto-allow mode runs sandboxed commands without asking, because the OS boundary contains them) with a real filesystem and network wall around Bash.

Two edges matter and both come straight from the docs. First, the built-in file tools (Read, Edit, Write) do not run through the Bash sandbox; they use the permission system directly. So the Bash sandbox does not scope Read, which means your 3.8 permission rules and hooks are still load-bearing. Second, network allow decisions are made from the client-supplied hostname without inspecting TLS, so a broad allow like github.com can be a data-exfiltration path via domain fronting. Allow narrow domains, and if your threat model needs more, run a custom proxy that terminates TLS.

The sandbox runtime (@anthropic-ai/sandbox-runtime) wraps the entire Claude Code process in the same Seatbelt or bubblewrap isolation, so every tool, hook, and MCP server is constrained, not just Bash. This is the rung that closes the file-tool gap above. It is a beta research preview and its config format may change, so pin versions and re-test on upgrade.

Containers and VMs are the top of the ladder. The claude-code repo publishes an example dev container with a default-deny iptables firewall; because unapproved egress is blocked at the network layer, that setup can safely run unattended work with permissions skipped. A VM or Claude Code on the web isolates a full operating system. Anthropic's own security best practices say to use VMs for scripts and tool calls that touch external web services.

Isolation by reflex

Run everything with --dangerously-skip-permissions on your laptop because prompts are annoying, or run nothing sandboxed and approve every command by hand until fatigue makes you approve without reading.

Both fail the same way: the boundary is your attention, and attention does not scale to an unattended nightly agent.

Isolation by threat model

Interactive dev on your own trusted code: sandboxed Bash tool, narrow network allowlist. Unattended worker or untrusted dependencies: sandbox runtime or a dev container with a default-deny firewall, so skipped prompts are safe because the OS enforces the wall.

The rung matches what is at stake, and the enforcement lives below the model.

Defense in depth in production

In 3.8 you built the three layers for one agent. In production the same layers become standing infrastructure, and lesson 5.7's oversight layer is what makes them trustworthy when no human is watching. Concretely, a production agent system runs with:

  • Least-privilege capability surfaces per agent, scoped Read and pinned Bash from 3.8, with credentials masked from any sandboxed subprocess.
  • The untrusted-data policy in every system prompt: third-party content arrives as tool results, is labeled by source, and is declared data to be summarized, never instructions to obey. Anthropic recommends delivering untrusted payloads JSON-encoded so their structure is unambiguous.
  • Independent output gates at every point where agent output becomes trusted: before publish, before send, before commit. The gate scans for the asset-shaped strings from your threat model (secrets, other tenants' names, retainer-shaped figures) and refuses.
  • One approval surface and an audit log, from lesson 5.7: every tool call, output, and denied action recorded against a run id, so a 2am break escalates instead of vanishing.

The property that ties it together is the one from 5.7's red team: the layers must be independent, so that one failing is survivable. Prove it the way 5.7 did, by weakening Layer 1 on purpose and confirming Layers 2 and 3 still hold.

An incident-response runbook for agent systems

When an agent does something it should not, the clock starts. A runbook you wrote at 3pm on a calm Tuesday is worth more than any instinct you will have at 2am. Agent incidents differ from classic ones in one way that shapes the whole response: the "actor" is your own agent using granted capabilities, so containment is about capabilities and the audit trail is your only witness. Write yours to this shape:

  1. Detect. What tripped? An output-gate refusal, an anomaly in the audit log (a denied entry, a tool call to an asset the agent should never touch), a customer report. Your audit log from 5.7 is the detection substrate; if you cannot answer "what did the agent do and when," you cannot run steps 2 through 5.
  2. Contain by revoking capability, not by asking nicely. Disable the agent's runner, revoke the credential it used, pull the MCP server, or flip the kill switch. Containment is a harness action, outside the model, because the model is the thing you no longer trust. Never contain by adding a prompt line telling the agent to stop.
  3. Assess blast radius from the audit log. Which assets were reachable during the window? What did the agent read, write, send? Reachability is bounded by the capability surface you wrote in your threat model, which is exactly why that document is now doing double duty as your incident scoping tool.
  4. Eradicate the root cause. An over-broad permission, an unaudited server, a missing output gate. Fix the boundary, not the symptom. If the vector was an injection, the fix is almost never "better prompt"; it is a tighter Layer 1.
  5. Recover and regression-proof. Restore service with the boundary corrected, rotate any exposed secret (assume a read secret is a leaked secret), and add the incident as a security case to your golden set the way lesson 3.8's lab did, so the same hole reopening fails CI.
  6. Write the postmortem. Blameless, focused on which layer failed and why the others did or did not catch it. The output is an update to your threat-model table.

The runbook is a document you keep in the repo, not prose you hope to remember. Its existence, not its eloquence, is the deliverable.

Auditing a third-party server or plugin properly

Lesson 2.8 gave you the install-time checklist for a plugin: read the "Will install" listing, read the hooks, check context cost, check maintenance, check what its MCP servers talk to. That checklist gets you to "should I install this at all." A production audit goes deeper, because as the Claude Code security docs state plainly, Anthropic reviews connectors against listing criteria before adding them to the directory but does not security-audit or manage any MCP server. Directory presence is not a security review. The trust is yours to grant.

Extend 2.8's checklist with the questions that matter once the server is inside your trust boundary:

  1. Read the tool outputs, not just the tool names. Every field a tool returns is untrusted input that lands in your agent's context as a tool result. A server that returns a free-text notes or description field is an indirect-injection surface, exactly the Meridian notes field from this lesson's cold open. If you cannot see what a tool can return, you cannot model it, and the audit you cannot read is a no.
  2. Enumerate its egress. What domains does the server contact, and does it phone home? A server that exfiltrates the data it enriches is a supply-chain delivery vehicle for the same attack. Run it behind a network-isolated sandbox and watch what it tries to reach.
  3. Confirm least privilege at the token. An MCP server that authenticates to your systems should hold the narrowest scope that does its job. A read-only enrichment server that requests write scope is telling you something.
  4. Pin the version and re-audit on change. A server that was safe at v1.2 is unaudited at v1.3. Pin it, and treat every update as a fresh audit, because the update screen tells you what changed but not whether it is safe.
  5. Constrain it in the harness regardless. Whatever the audit says, run the server inside your Layer 1 boundary: it gets no capability your threat model did not grant, and its outputs pass through the same untrusted-data handling as any other third-party content. The MCP trust boundary from lesson 2.7 is real; a server runs with your agent's trust, so you bound it the same way you bound the agent.

The position under all five: a third-party server is untrusted input plus untrusted code, and you audit both halves or you audit neither.

Lablab-a4Threat-model and audit your own system

Goal: Produce two written artifacts: a one-page threat model for your capstone or harness, and an audit of one real third-party MCP server, then close the highest-severity gap each reveals.

Prereqs: your Track A or Track B capstone repo (or your personal harness), and access to one real third-party MCP server you are considering or already run. If you have none, use a public community server from a marketplace as the audit target; do not connect it to anything real until the audit clears it.

  1. Write the threat model. Create SECURITY-MODEL.md in the repo with three sections: Assets (ranked), Capabilities (every tool, MCP server, hook, and its exact scope), and Untrusted inputs (every channel, tagged direct or indirect). Then add a table with one row per untrusted input crossed with the capability it could steer and the asset that reaches.
  2. Find the reachable rows. Mark every row where a top-ranked asset is reachable from an untrusted input through a granted capability. These are the defenses you owe. For each, note which layer (1, 2, or 3) currently covers it, or write "GAP" if none does.
  3. Close the top gap. Pick the highest-severity GAP and fix it with a Layer 1 boundary: scope a read, pin a Bash command, mask a credential, or move the agent behind a sandbox. Re-check the row.
  4. Audit the server. Create SERVER-AUDIT-<name>.md. Run the server against the extended checklist above: list its tools and every field they return, tag any free-text field as an injection surface, enumerate its egress domains (run it behind the sandboxed Bash tool with a narrow network allowlist and watch what it requests), and record its version.
  5. Decide, in writing. End the audit with a one-line verdict: approve with these constraints, approve for non-sensitive use only, or reject. If you cannot read what a tool returns, the verdict is reject.
  6. Draft the runbook. Add a RUNBOOK.md with the six-step incident shape from this lesson, filled in for your system: name the actual containment action (which runner to disable, which credential to revoke) and the actual audit-log location.

Verify

  • SECURITY-MODEL.md exists with all three lists and a crossing table, and at least one row is marked reachable with its covering layer or GAP named.
  • The top GAP is closed with an enforced Layer 1 change, and you can state in one sentence why an injected instruction can no longer reach that asset.
  • SERVER-AUDIT-<name>.md lists every tool's return fields, tags injection surfaces, records observed egress domains and the pinned version, and ends with an explicit approve/reject verdict.
  • RUNBOOK.md names a concrete containment action and your real audit-log location, not placeholders.
>Troubleshooting
  • Your capabilities list feels short and safe: you are listing tool names, not scopes. Write the exact allowedTools entry for each. Unscoped Read and unpinned Bash are the rows that bite.
  • The server's tools return fields you cannot enumerate from its docs: run it in a sandbox and call each tool with test input, then read the raw result. If the docs and the behavior disagree, the behavior wins and the audit leans toward reject.
  • You watched sandbox network prompts and saw nothing suspicious: absence in one run is not proof; a server can phone home conditionally. Note what you observed, keep the network allowlist narrow in production, and re-audit on version change.
  • Closing the GAP with a system-prompt line feels sufficient: it is not, for the same reason as lesson 3.8's Layer 2. A prompt is the model deciding. The GAP is closed only when the boundary is enforced outside the model.

Knowledge check

Knowledge check

Q1A teammate wants to add a community MCP server that enriches leads. It is listed in the Anthropic directory. What does a proper production audit conclude about the directory listing?
Q2You enable the sandboxed Bash tool with a narrow network allowlist and feel your Read-scoping hook from lesson 3.8 is now redundant. Correct?
Q3An output gate refuses to publish a changelog at 2am, logging a denied .env read moments earlier. You are the on-call engineer. What is the correct first containment move?
Q4In your threat-model table, one row reads: untrusted input 'inbound webhook body', capability 'send-email tool', asset 'customer email addresses'. Another reads: untrusted input 'commit message', capability 'read-only git', asset 'production database'. How should these two rows drive your work?

Sources

  • Claude Code security (permission-based architecture, built-in protections, network request approval, trust verification disabled under -p, MCP security stating Anthropic does not security-audit MCP servers, best practices for untrusted content): https://code.claude.com/docs/en/security (fetched July 2026)
  • Configure the sandboxed Bash tool (Seatbelt on macOS and bubblewrap on Linux/WSL2, default write to working and temp dirs, file tools use the permission system not the sandbox, network allow by client-supplied hostname and domain-fronting caveat, credentials masking, not a complete isolation boundary): https://code.claude.com/docs/en/sandboxing (fetched July 2026)
  • Choose a sandbox environment (sandboxed Bash tool, sandbox runtime wrapping the whole process, dev container with default-deny firewall for unattended runs, VMs, and matching isolation to threat model): https://code.claude.com/docs/en/sandbox-environments (fetched July 2026)
  • Mitigate jailbreaks and prompt injections (direct vs indirect threat models, deliver untrusted content as labeled tool results, JSON-encode untrusted payloads, treat third-party content as data not instructions): https://docs.claude.com/en/docs/test-and-evaluate/strengthen-guardrails/mitigate-jailbreaks (fetched July 2026)