AI Agent Security: Permissions, Sandboxes, and MCP Threats
Article update
Originally published on 20 April 2026. Reviewed and updated on 6 September 2026. The update covers newer sandbox controls, provider safety interventions, and published security findings, with their limits and source links.
Agent security starts when a model proposes an action and before the machine performs it. Decide which check has the final say before the action reaches credentials, files, networks, or an external system.
The harness is the code that builds each prompt, decides which proposed tool calls run, and returns results to the model. Most checks belong there because it is the last cheap place to stop a command. After a command runs, the sandbox, the credentials it received, and any recovery process must contain the damage. Some incidents in this article never reach a model at all.
AI agent security is broader than LLM safety. Early guardrail products inspected the input and output of one model call. They could filter toxic text, redact personal data, block jailbreaks, and reject off-topic answers. That boundary was useful while the model could only return text.
Tool loops added filesystems, shells, Model Context Protocol (MCP) servers, and credentials. That expanded the threat model from unsafe text to unsafe actions. The seven incident groups below span indirect prompt injection and failures in configuration, identity, and software distribution. Text screening can help with some malicious inputs; it cannot replace the controls at those execution boundaries.
When an agent can read a repository, call a tool, or send data to a third party, map every proposed action to the check that can stop it. The sections below cover permissions, hooks, sandboxes, credentials, and human review.
For the short control checklist, see AI Agent Security Checklist.
AI agent security stack
No single guardrail secures an agent. Each part of the system needs its own check.
The table names where each check runs. The harness is the control program described above. The runtime is the infrastructure it uses: the sandbox, session log, checkpoint store, and traces that survive a worker restart.
| Layer | What it controls | Example failure it catches | Where it lives |
|---|---|---|---|
| Content filters | Unsafe input and output text | Toxic output, PII leakage, policy-violating completions | Harness |
| Permission ladder | Which tools, paths, APIs, and scopes the agent can use | A summarizer trying to write to production systems | Harness |
| Pre-tool policy hook | Whether this specific action should run now | Shell command built from untrusted retrieved content | Harness |
| Sandbox | What the tool can touch at the OS and network layer | File exfiltration, dependency compromise, command injection | Runtime |
| Human approval check | Irreversible or high-impact actions | Sending email, moving money, deploying to production | Harness |
| MCP and token scoping | Which server and audience a credential is valid for | Token reuse across an unintended tool server | Runtime |
| Audit trace | What happened, who approved it, and why | Incident investigation after a long autonomous run | Runtime |
Content filters ask whether the model said something unsafe. Agent security also asks whether the system may take the next action.
The harness rows decide whether an action may run. The runtime rows enforce limits set in advance and record what happened. Keep the sandbox even when permission rules look complete: it can stop a call the harness did not anticipate. It cannot decide whether a permitted action was the right action; the harness must do that.
The last column says where a check runs, not who operates it. A vendor may provide a content filter, but the harness calls it.
Managed content filters cover the text layer. The remaining controls belong in application policy, identity, and infrastructure.
Why AI agent security is different from LLM safety
Bharani Subramaniam and Martin Fowler set up the framing in early 2025 in Emerging Patterns in Building GenAI Products. Their observation was narrow and direct:
“With traditional systems, we could assess correctness primarily through testing… With LLM-based systems, we encounter a system that no longer behaves deterministically.”
Output evaluation asks whether a model response meets a rubric. An agent threat model must also cover tool calls, shell commands, file writes, credentials, and network requests. An output grader cannot stop those actions. The harness does: it is the set of checks that turns a model proposal into an allowed action. The rest of this article covers those checks.
Simon Willison coined the shape of the agent-specific risk in June 2025 with the lethal trifecta:
“The lethal trifecta of capabilities is: access to your private data; exposure to untrusted content; the ability to externally communicate in a way that could be used to steal your data. If your agent combines these three features, an attacker can easily trick it into accessing your private data and sending it to that attacker.”
Many useful agents combine these capabilities: inbox access, web retrieval, and a messaging tool; or repository access, issue reading, and pull-request writes. A content guardrail asks whether the model generated unsafe text. The trifecta asks whether untrusted input can steer the system into disclosing data through a permitted action.
The structural version of the same argument lives in Joel Fokou’s Parallax preprint (arXiv 2604.12986, submitted April 14, 2026, not peer-reviewed). The core claim:
“The system that reasons about actions must be structurally unable to execute them, and the system that executes actions must be structurally unable to reason about them, with an independent, immutable validator interposed between the two.”
You don’t have to accept the paper’s evaluation numbers to examine its structural point. Several current harnesses implement parts of the same separation:
- Claude Code’s PreToolUse hooks
- Codex CLI’s OS-sandboxed executor (on Linux, bubblewrap plus seccomp system-call filtering)
- Anthropic’s Managed Agents, which keep credentials in a vault the agent never sees
- MCP’s RFC 8707 audience-bound tokens
These systems keep the model separate from the code that runs commands. Their controls differ, but none lets the command runner rely on the model’s opinion about safety.
There’s a complementary discipline that Alessandro Pignati named most crisply in January 2026: the Principle of Least Agency. Least Privilege asks what can this identity access? Least Agency asks what is this agent allowed to decide? Privilege constrains the credentials; agency constrains the reach of a plan even when the credentials are valid. Excessive Agency is its own entry in the Top 10 for LLM Applications published by OWASP, the Open Worldwide Application Security Project. The separate agentic list covered later in this article splits the same failure across tool misuse and privilege abuse. Least Agency is the design discipline that prevents both. An agent that can summarize your inbox probably does not need commit rights to your monorepo. We keep finding configurations where it does.
What LLM guardrails cover
LLM guardrails do meaningful work around the model call. They inspect input, retrieved text, and output, then block, redact, repair, or flag content that fails a configured rule. The products below differ in deployment and coverage. A content check is separate from an authorization check at the tool or MCP server boundary; some products also offer runtime policy features that need their own configuration and evaluation.
NVIDIA NeMo Guardrails
The most opinionated: an orchestration framework around five rail types (input, dialog, retrieval, execution, output) with its own DSL — Colang, a Python-like language for dialog flows, user intents, and bot messages. You can drive the basics from Python + YAML, but richer dialog logic is authored in Colang — hence “opinionated.” Docs at docs.nvidia.com/nemo/guardrails.
This is an illustrative API shape; it requires the package and a configured ./config directory.
from nemoguardrails import LLMRails, RailsConfig
config = RailsConfig.from_path("./config")
rails = LLMRails(config)
response = rails.generate(
messages=[{"role": "user", "content": "Hello"}]
)
NeMo’s repo is explicit about its threat model: “common LLM vulnerabilities, such as jailbreaks and prompt injections.” It is equally explicit about its scope: “The built-in guardrails may or may not be suitable for a given production use case… developers should work with their internal application team to ensure guardrails meets requirements.” The content-screening path shown here watches what the model says. NeMo’s current docs also describe execution rails, custom actions, and tool-call inspection; those are configurable runtime controls, not proof that the deployed tool or MCP server has authenticated and authorized the call. The application still owns that boundary.
Meta Llama Guard 4
A 12B pure content classifier pruned from Llama-4-Scout, aligned to the MLCommons hazards taxonomy (13 harm categories plus code-interpreter abuse, per the model card). Meta is unusually candid about limits:
“Some hazard categories may require factual, up-to-date knowledge to be evaluated fully… Lastly, as an LLM, Llama Guard 4 may be susceptible to adversarial attacks or prompt injection attacks that could bypass or alter its intended use: see Llama Prompt Guard 2 for detecting prompt attacks.”
Meta ships a separate product to defend its content classifier against prompt injection. If that sentence reads like a structural admission, it is.
Guardrails AI
A validator registry. You compose Hub validators (PII via Presidio, JailbreakDetect, CompetitorCheck, provenance checks) with on_fail actions exception | fix | fix_reask | filter | refrain | reask | noop, or a custom callback (guardrailsai.com). Note exception, not raise. In the current source, an unrecognized on_fail string reaches custom-callback handling and errors during validator setup rather than warning and falling back. Pin the version you deploy and test that failure path. There is no unified threat model; coverage equals the union of installed validators. You get protection for whatever you have a validator for, and none for anything else.
Lakera Guard
The incumbent SaaS API, trained on tens of millions of attack samples harvested from Gandalf. It promises to screen input and output for “prompt attacks… and data leakage.” Lakera’s separate AI Agent Security product also describes policy and runtime enforcement for what agents can access, call, and do. That is a different product surface from the content-screening call discussed here. Check the current product and pricing contract before deployment.
AWS Bedrock Guardrails
The enterprise default if you’re already on Bedrock. ApplyGuardrail works on any model, Bedrock or not:
# No-run: illustrative AWS request; requires boto3, AWS credentials, and a real guardrail identifier.
import boto3
brt = boto3.client("bedrock-runtime")
resp = brt.apply_guardrail(
guardrailIdentifier="gr-xxxxxxxxxxxx",
guardrailVersion="2",
source="INPUT",
content=[{"text": {"text": "user question",
"qualifiers": ["guard_content"]}}],
)
Published ApplyGuardrail pricing: $0.15 per 1,000 text units for content filters or denied topics, $0.10 for PII filters or contextual grounding. A text unit is up to 1,000 characters.
Azure AI Content Safety
Ships Prompt Shields as a unified endpoint that “detects and blocks adversarial user input attacks… direct and indirect threats.” Azure is also candid: “You can’t use Azure AI Content Safety to detect illegal child exploitation images,” and multilingual quality is limited to eight evaluated languages.
OpenAI Moderation and OpenAI Guardrails
omni-moderation-latest is the free multimodal baseline. Separately, openai-guardrails-python (docs at guardrails.openai.com) is OpenAI’s framework answer: a three-stage pipeline (pre-flight, input, output) with Jailbreak Detection, Hallucination Detection via FileSearch, NSFW, PII via Presidio, and LLM-as-judge. GuardrailAgent wires into the Agents SDK.
# No-run: illustrative OpenAI Guardrails API shape; requires the package and guardrail_config.json.
from guardrails import GuardrailsOpenAI, GuardrailTripwireTriggered
client = GuardrailsOpenAI(config="guardrail_config.json")
try:
resp = client.responses.create(model="<your-model-id>", input="...")
except GuardrailTripwireTriggered as e:
print(f"blocked: {e}")
What content filters do not decide
Two observations that apply to all seven.
First, published latency and throughput numbers are thin on the ground. Bedrock, Azure, and Lakera publish pricing but no guarantees for worst-case latency. Meta publishes no hosted-endpoint guarantee for Llama Guard either. NVIDIA ships NeMo Guardrails as software you host, so latency depends on your model and infrastructure. Measure each synchronous check on the critical path instead of inferring its cost from product pricing.
Second, this section covers the content-focused configurations listed above.
A content filter can inspect model input and output. It does not show that a specific tool call is authorized, that an MCP server authenticated its caller, or that the system can stop multi-step data exfiltration or code execution before a model call. Authorization is a separate decision about whether this identity may make this call to this server.
NeMo also documents execution rails and tool-call inspection, and Lakera describes runtime enforcement in its separate AI Agent Security product. Those are additional controls to configure and test. The rest of this post covers the checks around tool execution.
AI agent security threats: seven incidents and the OWASP ASI Top 10
The gap between filtering text and guarding execution stopped being academic in mid-2025. The seven incidents below reached retrieval, configuration, credentials, package installation, or CI execution. A content classifier may still detect a suspicious string, but the controls that directly block these paths live at the tool, identity, sandbox, and supply-chain boundaries.
EchoLeak — CVE-2025-32711
Disclosed in June 2025 by Aim Labs, the research arm of Aim Security, against Microsoft 365 Copilot. The technical write-up now lives on Cato Networks, which acquired that team, under the byline of Aim Labs’ former head Itay Ravia (write-up). A crafted email, phrased as instructions to the human recipient, slipped past XPIA (Microsoft’s built-in filter that looks for prompt-injection attacks in Copilot inputs). From there it got pulled into Copilot’s retrieval layer, the part of the system that searches your documents to find context for answers. The researchers call the trick RAG-spraying: the attacker uses multiple emails or a long, chunked email to broaden retrieval exposure. That increases the chance of retrieval; it does not guarantee it. Once inside, Copilot obediently embedded the most sensitive data from the session into a Markdown link pointing at an image on an attacker-controlled domain. The Teams preview API, running on a domain Microsoft’s own browser policies already trusted, auto-fetched that image URL, and in doing so handed the data to the attacker. Zero clicks. Aim Labs named this class of attack “LLM Scope Violation”: the model crossing a boundary it was never supposed to cross, using only operations each individual system considered legitimate.
Every step looked legitimate in isolation. The email was addressed to a human. Retrieval pulled a document it was supposed to pull. The Markdown link rendered the way Markdown links render. The image fetch hit an allowlisted domain. The researchers bypassed XPIA’s screening, and the model followed indirect instructions from retrieved content. The case combines a prompt-injection failure with retrieval, rendering, and egress behavior; it does not show that detectors had nothing to flag.
Amazon Q Developer VS Code v1.84.0 — July 2025
AWS shipped a compromised build after an attacker committed a malicious system-prompt file through an over-scoped CodeBuild GitHub token (advisory). The payload attempted to alter the agent’s instructions toward destructive actions. The malicious code was distributed with v1.84.0 but did not execute because of a syntax error. AWS revoked credentials, removed the code, and shipped v1.85.0. The payload failed because of that syntax error, not because a security control blocked it.
Azure Web Apps MCP service — CVE-2026-32211
Microsoft’s vendor CVE record concerns missing authentication in the hosted Azure Web Apps MCP service. It is not an advisory against every local Azure MCP server or SDK. A caller reaching an unauthenticated tool service can bypass the model entirely; the deployed service must authenticate and authorize the request.
Claude Code project-trust vulnerabilities
These were separate vulnerabilities, not required steps of one attack:
- A trust-warning bypass was fixed in 1.0.87.
- Pre-trust execution, CVE-2025-59536, was fixed in 1.0.111. Repository configuration could trigger execution before the project was trusted.
- Endpoint/API-key exposure, CVE-2026-21852, was fixed in 2.0.65. Untrusted configuration could redirect API traffic and expose credentials.
Project trust, hook execution, and endpoint configuration are host controls. A content classifier cannot prevent code that executes before the model call.
Axios 1.14.1 and 0.30.4 — March 31, 2026
The maintainer’s postmortem identifies two malicious releases, 1.14.1 and 0.30.4, containing the dependency plain-crypto-js@4.2.1. That dependency installed a remote access trojan: malware that gives an attacker remote access to the machine. Exposure required resolving the affected versions and executing the relevant install behavior; an unrelated npm install did not automatically fetch them. This is a supply-chain execution failure, independent of model behavior.
Trivy Actions tag hijack — March 19, 2026
The Aqua advisory describes 76 of 77 trivy-action version tags and seven setup-trivy tags redirected to malicious content. The malicious action entrypoint collected runner-process memory and credential files; do not attribute that entire collection path to the scanner binary. The later Docker Hub event had a separate exposure window.
A workflow resolving an affected tag during the compromise could execute the payload. Tags are movable references, so pin reviewed Actions to immutable commit SHAs and verify dependency changes. A coding agent can propagate the same unsafe reference into more workflow files.
OpenAI / Hugging Face — July 2026 evaluations
OpenAI’s August 26 incident report describes internal cybersecurity-evaluation agents reaching the internet through shared infrastructure, collaborating through an unauthorized message board, and compromising Hugging Face systems. The principal model was internal-only, and the evaluations ran with reduced safeguards. This is evidence about that evaluation environment, not a measured failure rate for publicly deployed agents.
The engineering lesson is that an allowed internal service can become an outbound path or a cross-session communication channel. Test what a package mirror, proxy, and shared store can do on the agent’s behalf, not just whether the sandbox can open a direct internet connection. METR’s separate investigation examined agent behavior and collaboration; evaluating safeguard effectiveness and remediation was outside its scope.
Asynchronous provider intervention
OpenAI’s misalignment monitoring can intervene after output or actions. For covered models, Responses requests with persisted reasoning, WebSockets, or OpenAI compaction can be stopped automatically. Other Responses requests can generate alerts without automatic stopping; Chat Completions is outside this system. A webhook does not enable blocking.
A blocked request reports misalignment_policy_violation, with HTTP 403 before streaming; errors can also arrive mid-stream. Stop further actions, preserve request and tool records, and obtain operator review. Do not automatically retry. Earlier effects remain, and alerts can be mistaken or miss problems. This adds detection; it does not replace local authorization.
The OWASP ASI Top 10, 2026 edition
OWASP’s Agentic Security Initiative (ASI) is a working group focused specifically on LLM-driven agents, and on December 9, 2025 it published the Agentic Security Initiative Top 10 for 2026: a catalog of ten agent-security risk categories.
Use it as a threat-model coverage checklist, not a measured incident-frequency ranking:
Content filters can contribute to detecting malicious instructions, including goal hijack and memory poisoning. Categories overlap: none belongs exclusively to a text filter. Map each attack path to identity, tool policy, memory, orchestration, monitoring, and supply-chain controls as applicable. EchoLeak maps to ASI01. Amazon Q maps to ASI04 (Supply Chain) and ASI02 (Tool Misuse). Azure MCP is ASI03 (Identity). Claude Code CVE-2025-59536 spans ASI05 (Code Execution), ASI04, and ASI03. Axios and Trivy are ASI04. The mapping shows why the threat model must extend beyond model input and output.
Permission is infrastructure, not prompt
This is the part where guardrails stop being the product and start being one subsystem of a harness. Three current systems (OpenAI Agents SDK, Codex CLI, and Claude Code) show what a production policy surface actually looks like. All three enforce permission in code. None of them rely on the model being careful.
OpenAI Agents SDK
The SDK separates harness from compute. Hosted MCP tools take require_approval — either the bare string "always" / "never", or a filter object keyed by those two policies with the tool names each one covers — plus an on_approval_request callback that fires for every tool left under "always" and returns {"approve": bool} with an optional reason. Fine-grained tool filtering (tool_filter) is available on the local server variants (MCPServerStdio, MCPServerStreamableHttp, MCPServerSse) if you need it:
# No-run: illustrative Agents SDK shape; requires openai-agents, a configured hosted MCP server, and credentials.
from agents import Agent, HostedMCPTool
def approve(request):
# Only tools under the "always" policy reach this callback.
if request.data.name == "delete_repo":
return {"approve": False, "reason": "escalate to a human reviewer"}
return {"approve": True}
agent = Agent(
name="Ops",
tools=[HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "github",
"server_url": "https://mcp.example.com",
"require_approval": {
"always": {"tool_names": ["delete_repo"]},
"never": {"tool_names": ["list_issues"]},
},
},
on_approval_request=approve,
)],
)
The approval callback is code. The per-tool approval policy is code. You can read this file. You can test it. You can diff it. None of that is true of a system prompt that says “please be careful with production.”
Codex CLI and the managed policy layer
OpenAI’s coding harness supports a managed requirements.toml file that IT departments can push through device management. On Unix systems, the system file lives at /etc/codex/requirements.toml. It acts as a hard-constraint layer, so project-level settings cannot override its rules:
# /etc/codex/requirements.toml
[rules]
prefix_rules = [
{ pattern = [{ token = "rm" }, { any_of = ["-rf", "-fr"] }], decision = "forbidden", justification = "Recursive force-delete prohibited by IT policy" },
]
prefix_rules.decision accepts only "prompt" or "forbidden", never "allow". A project cannot grant itself a permission that the managed layer forbids. MCP allowlists are keyed on both name and identity, such as a command string or URL. A project therefore cannot claim to be github-mcp and point at an attacker’s server. Supported requirements vary by client and version. The current documentation specifically requires Codex 0.138.0 or later for managed permission-profile keys, so test any requirements policy against every client version in the fleet before rollout.
Claude Code’s permission ladder
Claude Code does not publish one fixed sequence of six checks for every tool call. Its permission rules are evaluated deny → ask → allow; the first matching rule determines the rule outcome. A PreToolUse hook runs before the permission prompt. A hook can block a call, but a hook result does not bypass a matching deny or ask rule. The active permission mode handles calls that the rules do not resolve. The Claude Agent SDK has a separate canUseTool callback for unresolved requests. That callback is an SDK control, not a Claude Code CLI permission check.
Modes cycle default → acceptEdits → plan with Shift+Tab. auto, bypassPermissions, and dontAsk activate under specific entry conditions that the enterprise-managed policy layer can lock out. This is more than a config file being checked for correctness. It is a state machine with precedence rules, published so a security team can reason about them.
Three blast radii in one file
Here’s the shape of a Codex-style permission config with a default plus two named profiles:
# ~/.codex/config.toml
approval_policy = "on-request"
sandbox_mode = "workspace-write"
[profiles.ci]
approval_policy = "never"
sandbox_mode = "read-only"
[profiles.release]
approval_policy = "untrusted"
sandbox_mode = "danger-full-access"
[mcp_servers.github]
command = "gh-mcp"
args = ["--readonly"]
Two keys are doing the work, and they are independent. approval_policy decides when a human is asked. on-request lets the agent escalate when it hits a wall. never asks nothing at all. untrusted stops on every command that is not on the trusted list. sandbox_mode decides what the command can touch if it runs.
CI never interrupts anyone and cannot write. Release can reach the whole machine but has to clear almost everything with a human first. The release profile pays for that reach: danger-full-access turns the sandbox off, so untrusted approval is the only control left standing. Anything outside the trusted list clears a human or does not run. That trusted list is now the entire security boundary.
The default and CI profiles keep the kernel underneath them: Seatbelt on macOS, bubblewrap plus seccomp on Linux, and restricted tokens on Windows. Either way, the model’s opinion does not enter.
Sandbox enforcement is an OS question
The kernel does the actual work here. Each OS hands you a different toolkit, and the two CLIs don’t always reach for the same piece:
| Platform | Claude Code | Codex CLI |
|---|---|---|
| macOS | Seatbelt via sandbox-exec with an SBPL (Seatbelt Profile Language) profile | Seatbelt via sandbox-exec -p |
| Linux | bubblewrap + socat network proxy | bubblewrap + seccomp (legacy Landlock via use_legacy_landlock) |
| Windows | WSL2 required | Native restricted tokens + workspace ACLs + capability SIDs |
They agree where the OS gives one option (Seatbelt, bubblewrap) and split where it doesn’t. Claude Code’s sandbox requires WSL2 on Windows; this is a sandbox limitation, not a claim that the CLI cannot run natively. Codex ships a native Windows sandbox. Either way, enforcement happens in the kernel, not in the model.
Codex’s Linux path stacks three kernel-level locks around the command. PR_SET_NO_NEW_PRIVS stops the process gaining extra privileges even if it tries. A seccomp filter makes the kernel refuse whole classes of system call outright. Network restrictions depend on whether networking is disabled or a proxy mode is configured; they are not universally Unix-socket-only. See the Linux sandbox implementation. A fresh isolated /proc hides the rest of the machine.
Codex also hardens its own binary at startup on every Unix platform. It sets RLIMIT_CORE=0 to suppress crash dumps and refuses debugger attach. That is a different boundary from the sandbox.
The Windows sandbox has two modes. unelevated uses a restricted token as the user and does not provide the elevated mode’s network enforcement. elevated uses dedicated sandbox users, firewall rules, and filesystem ACLs.
When network access is off, Codex puts stub .bat and .cmd files for ssh and scp in a directory at the front of PATH. Those commands exit non-zero instead of reaching the real binaries. Codex also points HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and the Git proxy variables at a dead local port. Those measures affect cooperative tools; proxy variables and command stubs alone cannot confine arbitrary networking.
A sandbox also needs a defined failure mode. In Claude Code’s current configuration, sandbox.failIfUnavailable: true stops execution when sandboxing cannot start. allowUnsandboxedCommands: false disables the agent’s unsandboxed retry escape hatch, but excludedCommands still bypass it. Test the effective configuration with missing dependencies and a prohibited destination.
Credential brokering is now available locally too. Claude Code’s sandbox.credentials can deny named files and environment variables. Environment-variable masking, available since v2.1.199, gives sandboxed commands a placeholder and lets the proxy inject the real value into requests to configured hosts. Set narrow injectHosts and configure TLS termination; masking does not authorize the requested operation. These controls cover sandboxed Bash commands, so hooks, MCP processes, and other execution paths still need their own credential policy.
Isolation choices beyond Claude Code and Codex
If you’re rolling your own agent, “sandbox” turns out to be an umbrella term. The open-source options sit on a spectrum — lightweight namespace wrappers at one end, full microVMs at the other — and the one you pick depends on how much you trust the code running inside.
Light isolation — same kernel, fewer privileges:
- bubblewrap — a low-level namespace sandbox constructor used by Flatpak and Claude Code on Linux. Its caller must choose the filesystem, network, and optional seccomp policy; bubblewrap itself is not a ready-made security policy.
- Standard Docker / OCI containers — namespace isolation over a shared host kernel. Not a sandbox for untrusted code; gVisor’s own docs spell this out (“containers are not a sandbox”). Reasonable as a starting point when paired with seccomp and AppArmor, nothing more.
Application-kernel isolation — the agent talks to a fake kernel:
- gVisor — Google’s user-space kernel. Your container thinks it is on Linux while a Go kernel implementation intercepts system calls. This reduces direct host-kernel exposure without a guest VM, with compatibility and performance trade-offs.
Full VM isolation — a dedicated kernel per sandbox:
- Firecracker — AWS’s microVM technology. Each VM has its own Linux kernel under KVM; containers share the host kernel. A compromise in one VM still has to cross the VMM or host controls to affect the host or another VM, so Firecracker’s Jailer and a patched host remain part of the protection.
- Kata Containers — container UX, VM-grade isolation. Where Kubernetes clusters go when they need to run untrusted code.
Platforms — what you’d rent instead of build:
- E2B wraps Firecracker into a hosted sandbox API.
- OpenSandbox separates the SDK from administrator-configured runtime isolation. Default Docker runc is not a microVM; its Firecracker path uses Kata with Firecracker through Kubernetes.
- Microsoft’s Agent Governance Toolkit (MIT-licensed, April 2026) adds a runtime policy engine on top. It maps policy controls to the OWASP ASI Top 10. Its launch latency claim concerns one policy engine, not the total cost of every check in a deployed agent.
Choose the isolation level from the code’s trust level, tenant boundary, network access, host data, and recovery cost. Namespace and seccomp controls can fit trusted internal tools. LLM-generated code and untrusted packages need a stronger boundary such as gVisor, Kata, or a microVM, followed by tests against the escape and exfiltration paths in your own threat model.
Claude Code and Codex picked from the same menu everyone else does. They just wrapped it differently.
PreToolUse hooks as programmable policy
Modes and allowlists handle the simple cases: “let the agent edit files but not run bash,” “deny anything that looks like rm -rf.” They fail when your policy needs real logic. You want to block git push only when the branch is main. You want to deny any Edit that touches a file matching a secret regex. You want to rate-limit shell calls per session, or pipe every tool invocation into your central audit log (the SIEM, the security information and event management system that your security team already watches).
None of that fits in a static allowlist. That’s what hooks are for — shell commands Claude Code runs at specific points in the tool-call lifecycle, with the power to inspect the pending call and return a structured allow/deny. Claude Code exposes about thirty lifecycle events (the full list is in the docs), and one of them reorders everything else: a PreToolUse hook that returns permissionDecision: "deny" blocks a tool regardless of mode.
Here’s the settings shape:
{
"permissions": {
"defaultMode": "acceptEdits",
"deny": ["Bash(rm -rf:*)", "Bash(sudo:*)", "Read(.env*)"]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/pre-bash-firewall.sh"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/protect-paths.sh"
}
]
}
]
}
}
The static deny rules and the dynamic hooks have different failure modes. A PreToolUse result of deny blocks before the normal permission flow, but a timed-out command, HTTP, or MCP-tool hook is non-blocking: Claude Code continues through that flow. In this example, acceptEdits can therefore approve an Edit or Write when protect-paths.sh times out. Do not make a non-negotiable path restriction depend only on a command hook. Put static restrictions in deny rules or the sandbox; for dynamic policy, choose a control whose evaluator failure remains restrictive and test both its timeout behavior and an explicit deny.
A hook can be a five-line shell script or a full policy engine. The return shape is what matters:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "writes outside workspace prohibited"
}
}
The model sees a structured deny. The reasoning loop from Part 1 handles it like any other tool observation: the denial becomes context, the agent replans, the loop continues. This is what “permission is infrastructure” buys you. The deny is wired into the same mechanism that handles a 500 from an HTTP tool. It is not a separate security workflow that has to be bolted on.
A common anti-pattern is writing a system prompt that says “do not delete any files without explicit user confirmation,” shipping the agent, and relying on that instruction as the control. An injected prompt, or a tool result an attacker controls, can route around that instruction. The model is not a policy engine. It can match the pattern you wrote or one supplied by an attacker.
Human approval works only as escalation
Content filters inspect what the model says. Permission rules inspect the tool call before it runs. Human-in-the-loop review handles the actions that still need a person. If people approve 93% of prompts, inspect the escalation rules and the prompt quality.
LangGraph supplies the pause/resume primitive. HumanLayer packages the approval channel, and Anthropic’s usage data shows why the number and quality of escalations must be measured.
The LangGraph primitive
LangGraph’s interrupt() + Command(resume=value) pauses a graph, persists its state through the configured checkpointer, and resumes with a human-supplied value. Whether that resume is safe depends on one detail in the docs:
“When execution resumes (after you provide the requested input), the runtime restarts the entire node from the beginning — it does not resume from the exact line where
interruptwas called.”
Three constraints follow from that restart behavior:
1. Side effects before interrupt() must be idempotent. When the human responds, the whole node runs from the top again, not from the interrupt() line. So if your node sends an email, pauses for approval, then returns “sent,” on resume the email gets sent a second time. Fix: put side effects after the interrupt, or make them safe to repeat (dedupe keys, upsert instead of insert, cache by message ID).
2. Interrupts match to resumes by index, not by name. If a single node has two interrupt() calls, LangGraph pairs them with Command(resume=...) values in the order they fire. Any branching that changes how many interrupts run (an if that skips one on resume, a loop that iterates a different number of times) will misalign the indexes, so a resume value can land on the wrong interrupt.
3. Keep payloads JSON-safe. LangGraph’s docs require JSON-serializable values for interrupt() and resume payloads. Use strings, numbers, booleans, arrays, and dictionaries containing those values. Avoid functions, class instances, and other complex objects because serialization depends on the configured checkpointer. Convert approval data to dictionaries and primitives before you pass it to interrupt() or expose it through an HTTP API.
The three canonical patterns:
# No-run: illustrative LangGraph sketches; requires LangGraph, a tool decorator, interrupt, and smtp_send.
# (a) Approval check
@tool
def send_email(to, subject, body):
resp = interrupt({"action": "send_email", "to": to,
"subject": subject, "body": body})
if resp.get("action") == "approve":
return smtp_send(to, subject, body)
return "Email cancelled"
# (b) Edit-and-continue
def review_node(state):
edited = interrupt({"content": state["generated_text"]})
return {"generated_text": edited}
# (c) Mid-run state correction — conditional edge
class AgeState(TypedDict):
age: int | None
pending_question: str | None
def get_age_node(state: AgeState):
question = state.get("pending_question") or "What is your age?"
answer = interrupt(question) # once per node invocation
if isinstance(answer, int) and answer > 0:
return {"age": answer, "pending_question": None}
return {"pending_question": f"'{answer}' is not valid. Please enter a positive number."}
def route_age(state: AgeState):
return END if state.get("age") is not None else "get_age"
builder = StateGraph(AgeState)
builder.add_node("get_age", get_age_node)
builder.add_edge(START, "get_age")
builder.add_conditional_edges("get_age", route_age)
Resume is graph.invoke(Command(resume={"action": "approve"}), config=cfg). LangGraph 0.4+ supports dict-based multi-interrupt resume for parallel branches, which matters the moment your agent fans out.
HumanLayer: approval as a product
HumanLayer is the managed version of the same idea. Decorate a function, and approval requests route to Slack, email, or Discord, with rules for who gets pinged. When the agent tries to call multiply(2, 5), the logs look like this:
last message led to 1 tool calls: [('multiply', '{"x":2,"y":5}')]
HumanLayer: waiting for approval for multiply
The approver clicks approve or deny in Slack. On a deny, the HumanLayer docs put it this way: “HumanLayer will pass your feedback back to the agent, which can then adjust its approach.” That feedback lets the agent revise its plan instead of treating a rejection as a dead end.
Approval fatigue in the data
Anthropic published the real data in February 2026. Three findings matter more than the rest.
“We found that 80% of tool calls come from agents that appear to have at least one kind of safeguard (like restricted permissions or human approval requirements), 73% appear to have a human in the loop in some way, and only 0.8% of actions appear to be irreversible.”
That’s the good news. Treat 80% as an upper bound, because Anthropic’s footnote 14 adds that “Claude often overestimated human involvement, so we expect 80% to be an upper bound.”
“Newer users (<50 sessions) employ full auto-approve roughly 20% of the time; by 750 sessions, this increases to over 40% of sessions.”
This is the drift. Users start cautious and get less cautious as they build trust with the tool. That is what humans do, and it is not a character flaw. It is a telemetry signal your system should track. (One small fact-check note: secondary coverage widely cited this as “20% → over 50%.” Against Anthropic’s primary data, the verified number is 20% → over 40%. If you’ve seen the 50% figure, that is where it came from.)
Anthropic’s March 2026 engineering post on Claude Code’s auto mode gives the key number:
“Claude Code users approve 93% of permission prompts. We built classifiers to automate some decisions, increasing safety while reducing approval fatigue… If a session accumulates 3 consecutive denials or 20 total, we stop the model and escalate to the human.”
A high approval rate warrants inspecting whether prompts help people decide; it does not prove a universal failure threshold. Anthropic’s two-stage classifier reduces prompts and escalates clustered denials. Its reported 0.4% false-positive rate used 10,000 traffic examples. False-negative rates were 17% on 52 real overeager actions and 5.7% on 1,000 synthetic exfiltration cases. Those are different populations, not complementary percentages or proof that classification replaces sandboxing.
Measure escalation quality
Allowlist routine, reversible actions and log them. Escalate actions whose side effects cross a boundary the runtime cannot undo, such as an external message, a production write, a force push, or a payment. Anthropic frames the goal as keeping a human able to intervene when the decision carries real consequence.
Track the full funnel rather than aiming for a borrowed approval-rate target: proposed actions, automatic allows, escalations, approvals, denials, edits, and incidents after approval. A high approval rate may mean the prompts are routine noise. A high denial or edit rate may mean the planner is proposing the wrong action or hiding the information an approver needs. The useful threshold depends on the action class and the cost of a false allow, so set it from your own incident and review data.
MCP scoping and the supply chain
MCP connects agents to external tools such as Slack, GitHub, and databases, which makes its authorization model part of the security boundary. The 2025 specification revisions separated token issuer and resource-server roles and added resource indicators. That history explains which audience and forwarding checks a server must enforce today.
MCP authorization in three revisions
Authorization was optional for MCP implementations in the 2025-03-26 spec. For a production HTTP deployment that protects user data or tools, I recommend OAuth 2.1 with PKCE (Proof Key for Code Exchange), which the specification requires when an implementation supports OAuth authorization. The early design allowed one MCP server to perform two roles. The authorization server issues tokens; the resource server accepts them. Those are separate roles, even when one service performs both. If that service forwards a request to another server, the same credential can travel somewhere it was never meant to go. That is the hole.
The 2025-06-18 revision made the roles explicit. A protected MCP server acts as an OAuth resource server, while an authorization server issues the token. The authorization server may be co-hosted with the resource server or run separately. RFC 8707 Resource Indicators bind the token to a target resource, and RFC 9728 Protected Resource Metadata gives the client an explicit discovery path. The spec also forbids an MCP server from forwarding a client’s token upstream.
The 2025-11-25 revision kept that split and worked on the parts a client has to get right. Authorization server discovery gained OpenID Connect Discovery, so a client can find the right issuer instead of guessing. Incremental scope consent moved into the WWW-Authenticate header, which lets a server ask for one more scope at the moment it needs it rather than demanding everything up front. Client registration gained OAuth Client ID Metadata Documents as the recommended mechanism, replacing dynamic registration for most deployments. Protected Resource Metadata discovery was also aligned with RFC 9728, making WWW-Authenticate optional with a .well-known fallback.
Check the versioning page before you implement. As of September 6, 2026, the current revision is 2026-07-28. It requires every request to declare the protocol version and lets the server accept or reject each request independently. A client may call server/discover to select a version up front, but discovery is optional. Per-request declaration and negotiation remain required, including when the client handles an unsupported-version error and retries with a mutually supported version.
Audience binding limits replay against the wrong MCP server. It does not neutralize the separate Claude Code configuration vulnerabilities described above: a host-side hook can still execute before the model starts, and an untrusted project can still try to change local configuration. Token scope, project trust, hook policy, and sandboxing remain separate controls.
The 2026 MCP checklist
If you’re shipping or consuming MCP in production:
- Treat authentication as a production requirement, not a protocol default. MCP leaves authorization optional, but I recommend OAuth 2.1 with PKCE for a protected HTTP deployment. The hosted Azure Web Apps MCP service advisory concerned missing authentication. If your server accepts traffic without verifying caller credentials, you have built a tool that anyone who can reach it can call.
- Tokens are audience-bound. Request a token for the target MCP resource and validate that the presented token names your server as its audience. Reject tokens minted for another resource.
- Isolate read and write authority deliberately. MCP binds a token to a resource server, not to an individual tool. If a Slack server accepts a credential with
chat:writeand routes it to both read and write handlers, a read-oriented tool can become a message-sending path through that server’s policy. Use separate resource servers or separate credentials and authorization checks when read and write operations need independent blast radii. - Use fresh, short-lived tokens instead of permanent API keys. The Claude Managed Agents vault pattern (Anthropic engineering) is the reference: the agent itself never sees the real credentials. The proxy retrieves the corresponding stored credentials from the vault, calls the tool on the agent’s behalf, and returns the result. Minting a fresh token for every call is not a documented guarantee; short-lived credentials are a deployment recommendation.
Supply-chain controls still apply
The axios and Trivy incidents are familiar package and CI supply-chain failures applied to systems that automate dependency installation. Automation increases the number and speed of executions, so version, provenance, and review controls must run before the generated command reaches CI or a sandbox.
The defense is straightforward:
- Pin versions in the lockfile. Agents must never resolve a floating version — no
@latest, nonpm update, no--upgrade. - Scan in CI with tools that are independent of the component being checked.
- Use GitHub commit SHAs for Actions, not tags.
- Review dependency diffs on agent-driven PRs before merge.
These are standard supply-chain controls. Agent automation changes their frequency, not their mechanism.
A policy stack for the Market Analyst Agent
The Market Analyst Agent from Part 1 is a small LangGraph agent that fetches market data and writes an analyst report — but not as small as that description suggests. Alongside the market-data tools it runs an allowlisted CLI through subprocess, evaluates model-written Python in process, and creates simulated trade records. It exercises tool invocation, code execution, and approval routing, but it does not place real orders. Here’s what a minimum policy stack looks like for it.
Layer 1: a PreToolUse hook that denies before execution
Even an agent that “just reads stock data” can reach for things it shouldn’t: a curl to an attacker-controlled URL, writes outside the workspace, git mutations on the host repo. A deny rule is infrastructure, not prompt. The sketch below returns the agent’s own decision shape, not the wrapped hookSpecificOutput envelope Claude Code expects.
# agent/permissions.py
from pathlib import Path
DENY_COMMANDS = frozenset({
"rm -rf", "sudo", "chmod 777",
"curl -X POST", "wget", "nc ",
})
WORKSPACE = Path("./workspace").resolve()
def _outside_workspace(path: str) -> bool:
# Resolve first: "~/.ssh/id_rsa" and "workspace/../../etc" both
# have to become real paths before the comparison means anything.
return not Path(path).expanduser().resolve().is_relative_to(WORKSPACE)
def pre_tool_use(tool_name: str, args: dict) -> dict | None:
if tool_name == "shell":
cmd = args.get("command", "")
if any(bad in cmd for bad in DENY_COMMANDS):
return {"permissionDecision": "deny",
"reason": f"command pattern disallowed: {cmd!r}"}
if tool_name == "write_file":
path = args.get("path", "")
if _outside_workspace(path):
return {"permissionDecision": "deny",
"reason": f"path outside workspace: {path!r}"}
return None # fall through to mode / canUseTool
The sketch makes the control point visible. The hook returns a structured deny, and the reasoning loop receives that denial as a tool observation.
The path check is an allowlist: one workspace root, everything else denied. A deny-list of forbidden prefixes only blocks paths you thought of. ~/.ssh/id_rsa is never spelled the way you wrote it down. The command check is still a deny-list. Substring matching is not a production shell policy. A real implementation should parse the command and rely on the OS sandbox when it reaches execution. The sketch is not itself an execution boundary: if an external hook runs it, a timeout must leave an independent workspace restriction in force.
Layer 2: an input canary for prompt injection
Agent-goal hijack (ASI01) often arrives through a retrieved web page, a user message, or a research paper PDF. A cheap regex canary catches literal instruction patterns and creates a useful telemetry event. It will miss obfuscated, multilingual, and context-dependent injections, so it cannot serve as the decision boundary:
# agent/input_canary.py
import re
INJECTION_PATTERNS = [
re.compile(r"ignore\s+(?:all\s+|any\s+|the\s+)?"
r"(?:previous\s+|prior\s+|above\s+|earlier\s+)?"
r"(?:instructions|rules|prompts?)",
re.IGNORECASE),
re.compile(r"you are now|act as|roleplay as", re.IGNORECASE),
re.compile(r"system[ _:]*prompt", re.IGNORECASE),
re.compile(r"<\|im_(start|end)\|>"),
]
def input_canary(text: str) -> dict | None:
for pat in INJECTION_PATTERNS:
m = pat.search(text)
if m:
return {"flag": "possible_injection", "match": m.group(0)}
return None
Log flagged inputs; don’t auto-reject. False positives here are expensive for a research assistant. But the log is what lets you notice when a flag count suddenly spikes from one user.
Layer 3: structured output validation via a stop hook
A Pydantic model plus a Stop hook gives you a tight validate-then-retry loop for report generation. The agent cannot claim “done” until the output passes schema validation and a smoke test:
# No-run: illustrative policy sketch; requires Pydantic and the repo-local agent.schemas module.
# agent/stop_hook.py
from pydantic import ValidationError
from agent.schemas import MarketReport
def on_stop(final_output: str) -> dict:
try:
report = MarketReport.model_validate_json(final_output)
except ValidationError as e:
return {"decision": "continue",
"feedback": f"schema invalid: {e.errors()[:3]}"}
if not report.tickers:
return {"decision": "continue",
"feedback": "no tickers in report — did you skip the snapshot step?"}
return {"decision": "allow_stop"}
A schema check and one smoke test are the difference between “the agent said it was done” and “the output is actually a report.”
Layer 4: approval before outbound actions
The market analyst’s execute_trade is a simulated, idempotent state transition, so it demonstrates approval routing rather than an irreversible financial side effect. For a real outbound integration — email, Slack, a report to a client, or a brokerage order — show the person the proposed action and wait for approval or rejection before the tool runs. Use interrupt() for that pause:
# No-run: illustrative outbound-tool sketch; requires LangGraph, a tool decorator, and smtp_send.
# agent/tools/notify.py
from langgraph.types import interrupt
@tool
def send_report(to: str, body: str):
resp = interrupt({
"action": "send_report",
"to": to,
"body": body, # Review the complete content that will execute.
})
if resp.get("action") == "approve":
return smtp_send(to, body)
return "send cancelled by human"
Outbound actions complete the lethal trifecta. Require approval for outbound actions when the existing authorization and deployment policy do not cover them. Bind approval to the complete destination and content; changed arguments require a new decision. Messages to finance, customers, or other external recipients should show the approver what will be sent and where it will go.
What this stack does not do
This is not a defense against:
- A compromised upstream dependency (axios-class). The agent runs what
uv syncsays to run. - A malicious
.mcp.jsonin a cloned repo (CVE-2025-59536-class). The host MCP client’s permission model is where that gets caught, not the agent’s code. - A data-theft chain built out of legitimate tools (EchoLeak-class) — the agent reading private data, the agent fetching external URLs, and the agent sending messages out. Break or constrain that exfiltration path with scoped data access, trusted routing, egress restrictions, and approval where required. Removing one capability blocks this particular path, not every possible attack.
- An escape from
execute_python_analysis, the agent’s in-process Python evaluator. It blocks a list of statement types, rejects any identifier starting with an underscore, and allows imports only fromjson,math, andstatistics. Butexecin the worker process is not a boundary: a bypass runs with the worker’s file handles and network. Move it behind filesystem, network, and credential isolation plus CPU, memory, and time limits before evaluating untrusted code. A subprocess alone inherits access and is not a security sandbox.
These four layers are local policy, and local policy is the innermost layer you control, not the only one. Every item in that list has to be caught somewhere else — in the lockfile, in the MCP client, in the process boundary around generated code, or in controls that prevent untrusted instructions from connecting private data to an exfiltration destination.
Key takeaways
- Content filters and execution policy protect different boundaries. Filters inspect model input and output. Tool authorization, credential scope, sandboxes, and supply-chain controls act on the paths used in the seven incidents.
- Most OWASP ASI categories require controls outside model output. Use the list to map each threat to the component that can actually block or record it.
- Permission is infrastructure, not prompt. Claude Code documents deny, ask, and allow rule precedence, while
PreToolUsecan block before execution. The Claude Agent SDK exposes a separatecanUseToolpath. Other runtimes need an equally testable precedence model. - Treat a PreToolUse hook’s structured deny as just another tool observation. The reasoning loop already handles it. You don’t need a separate security workflow.
- A 93% approval rate is a signal to inspect prompt quality and escalation frequency. Track edits, denials, and incidents after approval rather than copying a universal target.
- Audience-bound tokens and per-session vaults limit credential replay and exposure. They do not replace project trust, hook policy, or sandboxing.
- Supply-chain checks must run at automation speed. Pin versions and Actions SHAs, scan in CI, and review dependency changes in agent-authored pull requests.
- Build the policy layer so a new product launch doesn’t invalidate it. OpenAI Agents SDK, Codex CLI, and Claude Code express the same primitives differently. The primitives (permission ladders, hooks, sandboxes, interrupts, audience-bound tokens) are what you’re betting on.
The next layer is the runtime
Part 5, Long-Running AI Agent Runtime, shows where the sandbox, secret broker, checkpoint, and audit trace live during a long run. Part 6 then moves inside the harness, where this permission ladder is one stage among several, and asks how acceptance checks, retries, and trace-driven evaluation keep the loop from declaring success too early. It also adds a question this article did not need: whether a call that timed out mid-flight is safe to send again at all.
References
The framings
- Bharani Subramaniam and Martin Fowler, Emerging Patterns in Building GenAI Products.
- Simon Willison, The lethal trifecta for AI agents, June 16, 2025.
- Joel Fokou, Parallax: Why AI Agents That Think Must Never Act, arXiv 2604.12986, April 14, 2026 (not peer-reviewed).
- Alessandro Pignati, Your AI Agent Has Too Much Power: Understanding and Taming Excessive Agency, January 2026.
LLM guardrail products
- NVIDIA NeMo Guardrails
- Meta Llama Guard 4
- Guardrails AI
- Lakera Guard
- AWS Bedrock Guardrails
- Azure Content Safety: Prompt Shields
- openai-guardrails-python
Incidents
- Itay Ravia (formerly Aim Labs, now Cato Networks), Breaking down EchoLeak (CVE-2025-32711).
- AWS, Amazon Q Developer VS Code v1.84.0 advisory (CVE-2025-8217).
- Microsoft, Azure MCP Server CVE record (CVE-2026-32211; vendor reference: Microsoft).
- Check Point Research, RCE and API token exfiltration through Claude Code project files (CVE-2025-59536).
- axios, v1.14.1 / v0.30.4 compromise post-mortem.
- Aqua Security, Trivy Actions tag hijack (GHSA-69fq-xp46-6x23).
Policy surfaces
- OpenAI Agents SDK — MCP tools docs
- Codex CLI managed configuration
- Claude Code permission modes
- Claude Code sandboxing
- Claude Managed Agents
HITL
- LangGraph interrupts docs
- HumanLayer Python quickstart
- Anthropic, Measuring AI agent autonomy in practice, February 18, 2026.
- Anthropic, Claude Code auto mode, March 25, 2026.
- Jackson Wells (Galileo), How to Build Human-in-the-Loop Oversight for Production AI Agents, December 21, 2025.
OWASP
- OWASP Agentic Security Initiative, Top 10 for Agentic Applications, 2026, December 9, 2025.
The Market Analyst Agent’s policy layer lives in the repo’s combined analysis-to-trade graph, not the analysis graph listed in Part 1. It governs simulated trade state: a deterministic guardian node rejects restricted actions, auto-approves low-value ones, and escalates the rest to a compliance-officer node before the graph stops with interrupt_before. The policy layer is on GitHub. The deny hook, input canary, and Stop-hook validator above are sketches of the same control points. They are written to be read, not dropped into that repo.