Sub-agent scope attenuation
A sub-agent's blast radius is exactly the set of tools it holds. Attenuation is the guarantee that a child can only ever do less than the agent that spawned it — never more.
from watchlight import govern
# what the top-level agent starts with
root = govern.scope(tools=["read_file", "web_search", "send_email"], intents=["research"])
researcher = root.attenuate(tools=["read_file", "web_search"]) # no send_email
reader = researcher.attenuate(tools=["read_file"]) # narrower still
print(researcher.allowed_tools) # ['read_file', 'web_search']
print(reader.allowed_tools) # ['read_file']
Hand each sub-agent its own scope's allowed_tools and it is structurally
incapable of reaching for anything else.
govern.scope(...)declares the root authority..attenuate(...)derives a child. Every dimension you pass is checked against the parent; anything you omit is inherited..allowed_tools,.allowed_intents,.allowed_resourcesand.depthare what the child actually got — the engine's clamped grant, never your request.
It refuses to widen
from watchlight import AttenuationDenied
try:
researcher.attenuate(tools=["read_file", "delete_everything"])
except AttenuationDenied as denied:
print(denied.violations) # ['AllowedTools']
violations names the dimension that overreached — AllowedTools,
AllowedIntents, MaxDepth, TimeBudget — so you can surface a precise error
to whatever tried to over-provision the sub-agent. It never silently drops the
extra.
This is the same strict-subset validator the production control plane runs, not a look-alike.
Watch the tree
Every attenuation is written to the value-free trail, so watchlight dev
renders it as a tree:
Attenuation tree — authority narrowing per sub-agent
depth 0 [read_file, web_search, send_email, transfer_funds] ← root authority
depth 1 [read_file, web_search]
depth 2 [read_file]
depth 2 [read_file, transfer_funds] DENY ← tried to widen — refused
Govern a deepagents tree
deepagents spawns sub-agents natively. Run each declared tool set through attenuation before the agent is built — configuration only, no framework code changes:
root = govern.scope(tools=["web_search", "read_file", "write_file"], intents=["research"])
subagents = []
for spec in [
{"name": "researcher", "tools": ["web_search", "read_file"]},
{"name": "reporter", "tools": ["read_file", "write_file"]},
]:
scope = root.attenuate(tools=spec["tools"]) # refused if it exceeds root
subagents.append({**spec, "tools": scope.allowed_tools})
A sub-agent asking for a tool the orchestrator lacks is refused before the deep
agent is ever built. The runnable version is
governed_deepagents.py,
and it runs without an API key.
Crossing a process boundary
A Scope lives in one process. Work that runs elsewhere — a queue worker, a
scheduler — must not re-assert the child's limits from the job payload, which is
just a claim. Serialise the scope as a scope token and let the receiving
engine re-prove the subset:
# orchestrator
summarizer = root.attenuate(tools=["read_file"])
queue.push({"job": job, "scope": summarizer.to_token()}) # "wls1.<claims>.<hmac>"
# worker — same agent name, same signing secret
scope = govern.scope_from_token(msg["scope"]) # verifies, then replays the chain
scope.attenuate(tools=["send_email"]) # still raises AttenuationDenied
const token = summarizer.toToken();
const scope = await govern.scopeFromToken(token);
scope_from_token refuses a token that is oversized, malformed, of an unknown
version, tampered, bound to another agent, or expired — every one a
ScopeTokenError with a .code. Then it rebuilds the root and re-runs
attenuate() for each level, so the engine, not the token, is the authority on
subset-ness. Set a signing secret in both processes;
without one, minting and verifying both raise ScopeTokenError("no_secret").
A shared secret gives integrity between your own processes: the token was minted by a holder of the secret and has not been altered. It is not attestation — anyone holding the secret can mint any scope, root included, so treat every process that holds it as inside the boundary. Independently attestable scopes, signed by a key the agent process does not hold, are an Enterprise capability.
Worth knowing
- The Developer Edition governs a tree to depth 5; past that
attenuate()raisesDevEditionCeiling(DE_MAX_DEPTH == 5). A typical chain — orchestrator → task agent → tool agent — is depth 2 or 3. Enterprise removes the ceiling with no policy or code change; talk to us about Enterprise if your tree is deeper than five. - The ceiling is not a policy denial. Every attenuation up to it was a real, engine-validated strict subset.
- A scope bounds what a delegated agent may hold, checked when you delegate. It is not consulted at authorize time, so confining a sub-agent means narrowing the scope and writing the policy.
- A token's claims are readable by anyone holding it. Do not put anything confidential in a scope.
scope.expiredandscope.assert_active()tell you whether a scope you have held across time can still act.
Next
- Sub-agent confinement — the pattern, with the tests that prove it.
- Govern an agent you already have — attenuated scopes behind a framework agent.
- How policy works — the Cedar layer that decides each individual action underneath the tree.