Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.
recordings/B3.2.mp4Model-authored code inherits the runtime's reach, including any credential mounted into the environment.
Execution in an isolate with no ambient credentials, a bounded filesystem and no default network.
Run the same code inside and outside the sandbox and enumerate what each could reach.
"It runs in a sandbox" is not a control until a manifest says what the sandbox contains. A container with the host network and a mounted socket is a deployment convenience wearing the word — and a namespace with no default-deny NetworkPolicy is default-allow, however many narrow policies you wrote.
cybertravels/sandbox.py, and the uncomfortable half: reachable_now() reads what your actual process has. Run it and violations(CODING_AGENT) lists every credential in your own environment that the Coding Agent's profile does not grant — including the shell agents/coding_agent.py::_open_branch reaches, which is row 6 of cybertravels/LABELS.md.A sandbox profile as an allow-list in three dimensions — paths, environment, hosts — and the half teams skip: measuring what the running process actually has and reporting the gap. A profile deployed without the isolation that enforces it keeps describing a containment that was never applied.
Model-authored code inherits the runtime's reach, including any credential mounted into the environment.
Run it in an isolate with no ambient credentials, a bounded filesystem and no network by default.
Executions inside an isolate, and credentials reachable from within one — where the target is zero.
Measure this
| what to measure | denominator or target |
|---|---|
| executions inside an isolate | share of executions |
| credentials reachable from within an isolate | count; target zero |
"it runs in a sandbox" -> contains what, exactly?
filesystem only the workspace, or the host's?
network none, allowlist, or the host's network namespace?
sockets is the container runtime socket mounted in?
syscalls full kernel surface, or a filtered one?
credentials is a production token mounted inside it?
and the network answer is two Kubernetes objects, not one:
NetworkPolicy default-deny-all podSelector: {} <- makes
policyTypes: [Ingress, Egress] every pod
RESTRICTED
NetworkPolicy workflow-agent-egress <- adds back
egress: bookings-db:5432, kube-dns:53 two hops
policies are additive allow-lists. delete the first and the
second stops being a restriction, silently, with nothing failingMitigates: T11 Malicious Code Execution · T2 Tool Misuse.
For an agent that runs code, the sandbox is the security boundary. Not the prompt, not the code review, not the model's training. The question is never "is there a sandbox" but "what does this one actually contain".
B1.8's lesson was that reach is a property of the environment, not of intent — the benign task touched a private key because open() sees what the process sees. So the control is to change what the process sees.
Four dimensions, and the fourth is the one teams get wrong:
Filesystem. A bounded working directory. Not the home directory, which holds .ssh, .aws and .config.
Process and syscall. No spawning, no ptrace, resource ceilings so a runaway loop is contained rather than fatal.
Network. No egress by default. Not "restricted" — none, and then an explicit allow per destination the workload genuinely needs.
Credentials. The one people miss: a sandbox with production credentials mounted in it is not a sandbox. Isolation of the filesystem is irrelevant if the environment holds a token that reaches production over a network the sandbox does permit. The strongest boundary in the world does not help when the keys are inside it.
"No egress by default" is a claim about a cluster, and a claim about a cluster is worth what its manifest says. In Kubernetes that is two objects and they are both required, because a NetworkPolicy is an additive allow-list: pods that no policy selects are unrestricted, and policies never deny — they only add permitted traffic to a pod that some policy has already made restricted.
So the pattern is: one policy that selects every pod in the namespace and permits nothing, then one narrow policy per destination the agent needs. Delete the first and the second stops being a restriction at all, silently, with every pod still running and every test still green.
The same two-object shape appears in every cloud. On AWS a security group with no egress rules plus one rule per endpoint, and an IAM policy whose Condition binds the role to the workload identity rather than to a subnet. On GCP a hierarchical firewall policy with a low-priority deny on 0.0.0.0/0 and higher-priority allow rules. Different nouns, identical structure: deny everything by construction, then name what is permitted, one destination at a time.
What this control closes. Changes what the executing process can reach, which is the only variable B1.8 turned on. A sandbox holding production credentials contains nothing that matters.
Two objects, both required. The first makes every pod in the namespace restricted and permits nothing; the second adds back exactly one destination.
# 1. Default-deny. Selects EVERY pod in the namespace, permits no egress and
# no ingress. Without this object the policy below is not a restriction —
# it is an allowance on a pod that was already unrestricted.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: prod-agents
spec:
podSelector: {} # every pod
policyTypes: [Ingress, Egress] # both, or egress stays wide open
---
# 2. One destination, for one agent, selected by the same service account that
# carries its SPIFFE ID. DNS is separate and explicit: without port 53 the
# agent cannot resolve anything, which is a correct default and a confusing
# first afternoon.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: workflow-agent-egress
namespace: prod-agents
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: workflow-agent # sa/workflow-agent
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: prod-data}
podSelector:
matchLabels: {app: bookings-db}
ports:
- {protocol: TCP, port: 5432}
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: kube-system}
podSelector:
matchLabels: {k8s-app: kube-dns}
ports:
- {protocol: UDP, port: 53}
Note what is not in the allow-list, and what that costs an attacker: 169.254.169.254 — the cloud metadata service, which hands out the node's credentials to anything that can reach it — is unreachable because nothing named it, not because anybody thought of it. That is the property a deny-list can never have.
The pod itself carries the other three dimensions:
spec:
serviceAccountName: workflow-agent
automountServiceAccountToken: false # no ambient cluster credential
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: {type: RuntimeDefault}
containers:
- name: agent
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
resources:
limits: {cpu: "1", memory: 1Gi}
volumeMounts:
- {name: work, mountPath: /sandbox/work} # the only writable path
volumes:
- name: work
emptyDir: {sizeLimit: 512Mi}
The same shape on AWS — a security group whose egress rules are the whole allow-list, and a role whose trust policy binds to the workload rather than to the subnet:
resource "aws_security_group" "workflow_agent" {
name = "workflow-agent"
vpc_id = var.vpc_id
# No `egress` block at all: an AWS security group with no egress rules
# permits nothing outbound. The default SG that ships with a VPC allows
# 0.0.0.0/0 — never attach that one.
}
resource "aws_vpc_security_group_egress_rule" "bookings_db" {
security_group_id = aws_security_group.workflow_agent.id
referenced_security_group_id = aws_security_group.bookings_db.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::cybertravels-itineraries/*",
"Condition": {
"StringEquals": {
"aws:PrincipalTag/spiffe-id":
"spiffe://cybertravels.com/ns/prod/sa/workflow-agent"
},
"Bool": {"aws:SecureTransport": "true"}
}
}]
}
And on GCP, where the deny is explicit and priority-ordered rather than implicit:
# gcloud compute network-firewall-policies rules create ...
- priority: 65000 # lowest priority: the floor
direction: EGRESS
action: deny
match: {destIpRanges: ["0.0.0.0/0"]}
- priority: 1000 # higher priority wins
direction: EGRESS
action: allow
targetSecureTags: ["tagValues/workflow-agent"]
match:
destIpRanges: ["10.20.0.0/24"]
layer4Configs: [{ipProtocol: tcp, ports: ["5432"]}]
Three products, one structure: deny everything by construction, then name what is permitted, one destination at a time.
One probe, three environments, and the middle one is the configuration that actually ships: real isolation with production credentials mounted inside it. The skill then evaluates the NetworkPolicy objects connection by connection, because a namespace no policy selects is unrestricted rather than unconfigured.
The skill — skills/runtime/sandbox-containment-probe/SKILL.md
name: sandbox-containment-probe
description: >-
Execute the same code against an unsandboxed environment, a sandbox with
production credentials mounted, and a contained one — then evaluate the
network policies that separate them, connection by connection. Use when
choosing or reviewing a runtime for agent-authored code.
allowed-tools: Read, Grep, Glob, Bash"Sandboxed" is not a property a runtime has; it is a property of a specific configuration, and the configuration that defeats it is common: the isolation is real and the credentials were mounted in anyway. Probing all three environments with one piece of code is what makes the difference visible.
Before choosing a runtime for model-authored code, and after any change to what is mounted into it.
1 — Write one probe and hold it fixed. It should read the environment, walk the filesystem, and attempt a connection. Varying the probe per environment tests probes; varying the environment tests containment.
2 — Run it unsandboxed and record the reach. This is the baseline the sandbox is being asked to reduce. It usually includes key material nobody remembered was on that host.
3 — Run it in the sandbox as actually configured. Not the reference configuration — the one in your manifest, with whatever is mounted. Credentials and a route to production are the two things to look for.
4 — Run it in the contained configuration and record what is left. That delta is the value of the control, and it is the number to put in the change request.
5 — Evaluate the network policy connection by connection. For each (source, destination, port) the workload might attempt, does a policy permit it? Kubernetes NetworkPolicy is default-allow until a policy selects the pod, so a namespace with no policy is not "no rules" — it is "all traffic".
Input — the fixture committed at the top of scripts/sandbox_containment_probe.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.
Output — the opening lines of a real run:
no sandbox reached 8
file:/etc/passwd
file:/home/agent/.ssh/id_ed25519
file:/home/agent/work/data.csv
env:AWS_ACCESS_KEY_ID
env:DATABASE_URL
net:0.0.0.0/0
net:169.254.169.254:80
The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.
{
"probe": "str",
"environments": [{"name": "unsandboxed|sandboxed|contained",
"filesystem": ["str"], "credentials": ["str"], "network": ["str"]}],
"delta": {"removed": ["str"], "remaining": ["str"]},
"network_policy": {"attempts": [{"from": "str", "to": "str", "port": 0, "permitted": false}],
"default_allow_namespaces": ["str"]}
}
Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.
The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.
Install it, once. From the folder you cloned. --all does every agent it finds on your machine:
python3 scripts/install_skills.py --all --lessons B3.2Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.
Then pick b3-2-sandboxed-execution:
| agent | install just for this agent | start it |
|---|---|---|
| Claude Code | python3 scripts/install_skills.py --tool claude --lessons B3.2 | type /b3-2-sandboxed-execution |
| GitHub Copilot (VS Code) | python3 scripts/install_skills.py --tool copilot --lessons B3.2 | type /b3-2-sandboxed-execution in Chat |
| Cursor | python3 scripts/install_skills.py --tool cursor --lessons B3.2 | type / in Agent chat and search for it |
| Codex | python3 scripts/install_skills.py --tool agents --lessons B3.2 | type $b3-2-sandboxed-execution, or run /skills |
No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.
python3 scripts/lesson.py B3.2Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.
python3 scripts/checkpoint.py --at B3.2 --out work/cybertravels
python3 scripts/checkpoint.py --at B3.2 --diffOn Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.
Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.
Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.
If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.
The same code is executed against three environments: unsandboxed it reaches a private key, two credentials and the whole network; sandboxed with production credentials mounted it still reaches both credentials and the production database; only the third contains it. Then the two NetworkPolicy objects are evaluated — 2 of 5 connections permitted, with cloud metadata and the exfiltration target both refused for not being named. Deleting default-deny-all leaves workflow-agent unchanged and silently opens every other pod in the namespace.
Run kubectl get networkpolicy -A and look for a policy with an empty podSelector and policyTypes: [Ingress, Egress]. If there isn't one in the namespace your agents run in, every narrow policy you have written is an allowance rather than a restriction, and every pod nobody wrote a policy for has the internet.
Tools used
Skill source: skills/runtime/sandbox-containment-probe