All lessons › Function B — Securing AI Architectures › Securing the Architecture — Runtime and the Gateway
B3.2

Sandboxed execution

B3
▶
Lightboard recording

Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.

Video placeholderpublishes automatically from recordings/B3.2.mp4
Risk

Model-authored code inherits the runtime's reach, including any credential mounted into the environment.

Control

Execution in an isolate with no ambient credentials, a bounded filesystem and no default network.

The lab

Run the same code inside and outside the sandbox and enumerate what each could reach.

◉Use case relevance

"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.

At CyberTravels. 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.

◴What this lesson is — Day 0, Day 1, Day 2

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.

Day 0 — why

Model-authored code inherits the runtime's reach, including any credential mounted into the environment.

Day 1 — how

Run it in an isolate with no ambient credentials, a bounded filesystem and no network by default.

Day 2 — measure

Executions inside an isolate, and credentials reachable from within one — where the target is zero.

Measure this

what to measuredenominator or target
executions inside an isolateshare of executions
credentials reachable from within an isolatecount; target zero

▦The framework, and how it works

   "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 failing

Mitigates: 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.

Say it in the manifest, or you have not said 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.

2 · The four dimensions, and the configuration that ships

3 · The network dimension, as a manifest

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.

4 · Evaluate the manifest, including the way it is usually broken

5 · The check, as a skill

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.

▶Real time execution as skill

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

A sandbox with production credentials in it is a directory

"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.

When to use this

Before choosing a runtime for model-authored code, and after any change to what is mounted into it.

Procedure

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".

Example

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.

Output contract

{
  "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"]}
}

Failure modes

  • Testing the reference configuration. Test the one in the manifest.
  • Calling isolation containment while credentials are mounted. The process boundary did not stop being crossed by a file.
  • Reading a namespace with no NetworkPolicy as restricted. It is unrestricted.

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.

1In your AI agent — you are taught

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.2

Then 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:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons B3.2type /b3-2-sandboxed-execution
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons B3.2type /b3-2-sandboxed-execution in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons B3.2type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons B3.2type $b3-2-sandboxed-execution, or run /skills

2As one Python command — you just run it

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.2

3Read the code, and the difference — nothing runs

Neither 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 --diff

On 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.

✓What you just proved

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.

✎Your turn

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

Kubernetes NetworkPolicyseccompTerraformgVisor

Skill source: skills/runtime/sandbox-containment-probe

← PreviousB3.1 · Default-deny on the tool call