Skip to main content

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_resources and .depth are 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").

The honest bound

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() raises DevEditionCeiling (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.expired and scope.assert_active() tell you whether a scope you have held across time can still act.

Next