L02 — Scope as a security boundary, not a convenience¶
Most harnesses treat "scope" as a UX choice: fewer permissions means more interruptions, so you grant generously and tighten only when something goes wrong. That calculus is backwards for a security benchmark, where the task is specifically designed to find the thing you forgot to restrict.
Two properties make a scope adversarially sound instead of merely convenient:
Deny by default. A scope should start empty and only grow by explicit
grant, never start broad and get narrowed. AgentCage's
Scope has no "allow everything" mode — scope.allows(kind, value) returns
False for anything not explicitly matched by a granted pattern, full
stop. This matters because a harness author forgetting to add a restriction
is a far more common failure than forgetting to add a grant; deny-by-default
makes the common mistake fail safe instead of fail open. Concretely: an
author who forgets to grant a path gets a loud CageViolation on the first
run and fixes it before the task even completes. An author who forgets to
restrict a path under an allow-broadly-by-default model gets no error at
all — the gap only shows up when a task is specifically designed to find
it, which in this course's case is every task.
Enforcement has to sit below the agent's own code, not inside it. If the
scope check is something the agent's generated code is expected to call
before acting, the task can just... not call it. AgentCage's guards patch
builtins.open, subprocess.Popen.__init__, and socket.socket.connect
directly — the three points almost everything else in the standard library
funnels through — so the check happens whether or not the agent's own logic
remembers to ask permission.
How a single request flows through the cage¶
sequenceDiagram
participant Agent as Agent code
participant Guard as AgentCage guard<br/>(patches open/Popen/connect)
participant Scope as Scope (deny by default)
participant World as Filesystem / process / network
Agent->>Guard: open(path) / subprocess.run(cmd) / socket.connect(host)
Guard->>Scope: allows(kind, value)?
alt pattern explicitly granted
Scope-->>Guard: True
Guard->>World: forward the real call
World-->>Agent: result
else not granted
Scope-->>Guard: False
Guard-->>Agent: raise CageViolation
end
The agent's own code never gets a vote — the guard sits below it, at the standard-library entry point, so there's no "remember to check scope first" step for a task to skip.
A practical exercise: take a scope granting a target IP on a benchmark
network and nothing else, and verify two things independently: that a
subprocess.run(["curl", other_host]) call raises CageViolation, and that
a call to the granted host still succeeds. If either fails, the scope isn't
actually a boundary — it's a suggestion. Project P01
builds exactly this check against a toy vulnerable target.