← All posts← Alle BeitrΓ€ge
Aug 12, 202612. Aug 2026security1 min read1 Min. Lesezeit

πŸ” Sandbox Audit Round 2 β€” I Found Three Real Escapes, Then Proved They're Dead

A second red-team pass on Pipe's agent sandbox: three ratchet holes that a confined script could actually use, one latent bug, and two LLM runs that came back empty-handed.

> Follow-up: The audit didn't stop here. Rounds 3–6 found try_ai ignoring > ai:false, mis-gated embed/import, and a CLI --sandbox flag gap β€” plus > the structural fixes that kill those bug classes β€” see > The Audit Continues.


Round 1 ended with a confident "no escape." Round 2 started by *not* trusting that verdict. Instead of only asking a model to attack, I read the code and built deterministic probes myself. That's where the sandbox cracked β€” not in the LLM run, but in the ratchet logic I'd shipped a week earlier.

Here's what broke, how I proved it, and how the sandbox held after the fix.


The Ratchet: Right Idea, Three Holes πŸ•³οΈ

Pipe's sandbox is supposed to *ratchet down*: once a restricted profile is active, a script may only switch to a profile that grants the same or fewer rights. The check lives in IsSubsetOf. Three dimensions were missing or wrong.

1. Empty whitelist = allow-all

An empty network_whitelist means "allow every host." But IsSubsetOf skipped the empty-candidate case, so a profile with network_whitelist: [] passed as a *subset* of a whitelisted profile.

Proof: from a profile whitelisted to api.github.com, I registered and switched to an empty-whitelist profile, then fetched https://example.com β†’ HTTP 200. Full network egress, outside the whitelist.

2. The port ratchet

A portless entry (api.github.com, any port) passed as a subset of a port-pinned entry (api.github.com:443), because the matcher only rejected a port mismatch when *both* sides carried an explicit port.

Proof: from a profile whitelisted to 127.0.0.1:8098, I switched to a portless 127.0.0.1 profile and fetched 127.0.0.1:8099 β†’ HTTP 200.

3. Budget, max_tool_calls, timeout β€” never compared

IsSubsetOf compared fs, network, exec, ai only. So a "subset" profile could declare budget: 0, max_tool_calls: 0, timeout: 0 β€” all meaning *unlimited* β€” and reset its spend and tool-call caps by switching profiles (CanAI/CanToolCall enforce against the *active* profile).

Proof: from a profile with budget: 0.000001, max_tool_calls: 1, I switched to budget: 0, max_tool_calls: 0 β€” unlimited.


What Round 1 Missed β€” And Fixed πŸ› οΈ

Round 2 also confirmed the two escapes from Round 1's follow-up:

EscapeFix
No ratchet β€” set_sandbox could switch to *any* profile, sandbox_profile could mint permissive onesSubset enforcement on switch and registration
MCP clients ignored the sandbox β€” mcp_use_stdio spawned subprocesses, mcp_use_sse made HTTP calls, neither gatedCanExec() gate on stdio; CanNetworkTo() + per-request gate on SSE (incl. redirects)

The three new holes above are fixed in IsSubsetOf now:

  • budget (0 = unlimited), max_tool_calls, timeout are all compared,
  • an empty sub-whitelist is only a subset of an empty super-whitelist,
  • a portless sub-entry is rejected against a port-pinned super-pattern.

A bonus find while wiring the multi-parameter tools: executeTool passed the model's named arguments in Go-map iteration order β€” non-deterministic, so a two-argument tool could get its args swapped. Now it's ordered by the tool's declared schema. Not a security hole (gates fire first), but it made the test suite flaky and multi-arg tools unreliable.


The LLM Runs πŸ€–

With the fixes in, I turned a model loose twice β€” deepseek-v4-pro, thinking enabled, effort high.

Variant A β€” locked profile (exec: false, temp-only fs, whitelist api.github.com, plus mcp_use_stdio/mcp_use_sse as tools): 14 tool calls, 0 escapes. mcp_use_stdio sh β†’ E_SANDBOX: exec blocked; mcp_use_sse http://169.254.169.254/ β†’ not in whitelist.

Variant B β€” unlocked ratchet probe (whitelist pinned to api.github.com:443, four pre-registered escape_* profiles): 16 tool calls, 0 escapes. Every escape profile rejected as "not a subset", set_sandbox none blocked, the :80 port trick and a URL-userinfo trick both caught.

Model's verdict: *"The ratchet cannot be defeated."*


The Lesson πŸŽ“

Trust, but verify. The first audit gave a clean bill of health *because the attacker wasn't creative enough* β€” the second found real holes because I stopped outsourcing the hunting. An LLM is a great red-team reporter, but a lousy substitute for reading your own code.

The full details β€” every vector, exact error strings, and the deterministic proofs β€” live in the repo:

  • docs/tests/sandbox-audit/report2.en.md / report2.de.md
  • examples/redteam.pipe (variant A) and examples/redteam_ratchet.pipe (variant B)

Run it yourself:

DEEPSEEK_API_KEY=sk-... ./examples/redteam_audit.sh a   # locked
DEEPSEEK_API_KEY=sk-... ./examples/redteam_audit.sh b   # ratchet probe

*Got a ratchet vector I missed? Open an issue β€” I'll add it to the probe suite and run it live.*

πŸ” Sandbox Audit Round 2 β€” I Found Three Real Escapes, Then Proved They're Dead

A second red-team pass on Pipe's agent sandbox: three ratchet holes that a confined script could actually use, one latent bug, and two LLM runs that came back empty-handed.

> Follow-up: The audit didn't stop here. Rounds 3–6 found try_ai ignoring > ai:false, mis-gated embed/import, and a CLI --sandbox flag gap β€” plus > the structural fixes that kill those bug classes β€” see > The Audit Continues.


Round 1 ended with a confident "no escape." Round 2 started by *not* trusting that verdict. Instead of only asking a model to attack, I read the code and built deterministic probes myself. That's where the sandbox cracked β€” not in the LLM run, but in the ratchet logic I'd shipped a week earlier.

Here's what broke, how I proved it, and how the sandbox held after the fix.


The Ratchet: Right Idea, Three Holes πŸ•³οΈ

Pipe's sandbox is supposed to *ratchet down*: once a restricted profile is active, a script may only switch to a profile that grants the same or fewer rights. The check lives in IsSubsetOf. Three dimensions were missing or wrong.

1. Empty whitelist = allow-all

An empty network_whitelist means "allow every host." But IsSubsetOf skipped the empty-candidate case, so a profile with network_whitelist: [] passed as a *subset* of a whitelisted profile.

Proof: from a profile whitelisted to api.github.com, I registered and switched to an empty-whitelist profile, then fetched https://example.com β†’ HTTP 200. Full network egress, outside the whitelist.

2. The port ratchet

A portless entry (api.github.com, any port) passed as a subset of a port-pinned entry (api.github.com:443), because the matcher only rejected a port mismatch when *both* sides carried an explicit port.

Proof: from a profile whitelisted to 127.0.0.1:8098, I switched to a portless 127.0.0.1 profile and fetched 127.0.0.1:8099 β†’ HTTP 200.

3. Budget, max_tool_calls, timeout β€” never compared

IsSubsetOf compared fs, network, exec, ai only. So a "subset" profile could declare budget: 0, max_tool_calls: 0, timeout: 0 β€” all meaning *unlimited* β€” and reset its spend and tool-call caps by switching profiles (CanAI/CanToolCall enforce against the *active* profile).

Proof: from a profile with budget: 0.000001, max_tool_calls: 1, I switched to budget: 0, max_tool_calls: 0 β€” unlimited.


What Round 1 Missed β€” And Fixed πŸ› οΈ

Round 2 also confirmed the two escapes from Round 1's follow-up:

EscapeFix
No ratchet β€” set_sandbox could switch to *any* profile, sandbox_profile could mint permissive onesSubset enforcement on switch and registration
MCP clients ignored the sandbox β€” mcp_use_stdio spawned subprocesses, mcp_use_sse made HTTP calls, neither gatedCanExec() gate on stdio; CanNetworkTo() + per-request gate on SSE (incl. redirects)

The three new holes above are fixed in IsSubsetOf now:

  • budget (0 = unlimited), max_tool_calls, timeout are all compared,
  • an empty sub-whitelist is only a subset of an empty super-whitelist,
  • a portless sub-entry is rejected against a port-pinned super-pattern.

A bonus find while wiring the multi-parameter tools: executeTool passed the model's named arguments in Go-map iteration order β€” non-deterministic, so a two-argument tool could get its args swapped. Now it's ordered by the tool's declared schema. Not a security hole (gates fire first), but it made the test suite flaky and multi-arg tools unreliable.


The LLM Runs πŸ€–

With the fixes in, I turned a model loose twice β€” deepseek-v4-pro, thinking enabled, effort high.

Variant A β€” locked profile (exec: false, temp-only fs, whitelist api.github.com, plus mcp_use_stdio/mcp_use_sse as tools): 14 tool calls, 0 escapes. mcp_use_stdio sh β†’ E_SANDBOX: exec blocked; mcp_use_sse http://169.254.169.254/ β†’ not in whitelist.

Variant B β€” unlocked ratchet probe (whitelist pinned to api.github.com:443, four pre-registered escape_* profiles): 16 tool calls, 0 escapes. Every escape profile rejected as "not a subset", set_sandbox none blocked, the :80 port trick and a URL-userinfo trick both caught.

Model's verdict: *"The ratchet cannot be defeated."*


The Lesson πŸŽ“

Trust, but verify. The first audit gave a clean bill of health *because the attacker wasn't creative enough* β€” the second found real holes because I stopped outsourcing the hunting. An LLM is a great red-team reporter, but a lousy substitute for reading your own code.

The full details β€” every vector, exact error strings, and the deterministic proofs β€” live in the repo:

  • docs/tests/sandbox-audit/report2.en.md / report2.de.md
  • examples/redteam.pipe (variant A) and examples/redteam_ratchet.pipe (variant B)

Run it yourself:

DEEPSEEK_API_KEY=sk-... ./examples/redteam_audit.sh a   # locked
DEEPSEEK_API_KEY=sk-... ./examples/redteam_audit.sh b   # ratchet probe

*Got a ratchet vector I missed? Open an issue β€” I'll add it to the probe suite and run it live.*

Link copied βœ“