遞迴的盡頭:Agent 自舉如何移動權限邊界
递归的尽头:Agent 自举
作者分享內部 Agent 生態系統的權限改造經驗,主張以協調器簽發的租約管理遞迴生成,避免 Agent 自行改寫權限邊界。租約限制深度、扇出、token 預算、執行時間和能力;子 Agent 的讀取權限可受限繼承,寫入權限須按租約授予,註冊 MCP 伺服器則不會繼承。作者將寫入、生成子 Agent 和註冊伺服器保留人工審批,並指出審批會增加延遲,這套安排尚未經受主動尋找漏洞的 Agent 考驗。
The third night, the page was the same line I'd read the two nights before:
policy.deny agent=catalog-sync tool=inventory.write reason=not_in_allowlist
catalog-sync had been writing to inventory every twenty minutes for six months. It was still supposed to. The list hadn't changed. The business had.
We owned the permission layer for an internal agent ecosystem — a dozen-ish agents at the time, each one wired to a handful of internal APIs. Version one was a static allowlist: agent X gets these six tools, no negotiation, no runtime decisions, trivial to review. For about five months it looked like the mature choice. Then one release split a service in two and moved half the tool names with it. Intent didn't change. Every one of those requests was legitimate work. Half of them started coming back denied, and the denials arrived as pages, because a reconciliation loop that gets denied keeps asking until a human makes it stop.
That's how I ended up awake three nights in a row over a permission list that was technically correct and operationally worthless.
We spent a week patching the list before admitting the model was wrong, and rebuilt it as dynamic authorization: when an agent hits the edge of what it holds, it doesn't just die and it doesn't get a blanket exception. It opens a scoped request against the grant it's acting under, someone on call approves or denies, the grant expires on its own, and the decision stays attached to the trace. The boundary moves when the business moves, and every move has a name on it.
I keep thinking about that week, because recursion is the same problem with the lid off.
In an MCP ecosystem, an agent calls tools, reads resources, writes memory. That already puts it inside your trust boundary. Give it the ability to spawn sub-agents, register new MCP servers, or edit its own prompt, and you've handed it the ability to move that boundary. A child at depth 3 that can register a server can manufacture a tool you never granted anyone. A sub-agent that can rewrite its parent's system prompt can rewrite its own leash. Recursion stops being a scaling trick the moment the thing doing the recursing can also change the rules of the recursion. Once that's true, the depth setting is the least interesting number in the system. The interesting one is the list of capabilities that will never be delegable to anything, at any depth, by any actor.
So every spawn gets a lease, and the orchestrator issues it.
$ agentctl lease inspect lease-9f2c
lease_id lease-9f2c
issuer orchestrator # not the agent, not the parent
parent planner
depth 3 / 3
fan_out 2 / 4
token_budget 1.4M / 2M
wall_clock 31m / 60m
capabilities search.read, tickets.read
expires 14m
The issuer line is the whole design. Our first attempt let the parent pass its own depth counter down as an argument — effectively spawn(depth=my_depth+1) — and we trusted the number we got back. It ran clean for a while, and then we had a cascade where a chain of children each reported depth 1, so the cap of 3 never fired, and thirty-odd agents spent an afternoon convinced they were all the root of the tree. Nothing malicious. Just a counter that the counted thing was allowed to write.
Now the orchestrator stamps the lease, the agent gets an opaque handle, and it never holds a writable copy of its own limits. It can ask whether it's allowed and get an answer. It cannot answer for itself.
$ agentctl spawn --from lease-9f2c --role summarizer
denied reason=depth_exceeded
parent_reported_depth=0 (ignored, lease says 3)
A depth-3 child that needs a depth-4 worker has exactly one path: the orchestrator issues a new lease with depth 4 on it, and that decision has an owner. The default answer to "can I go one level deeper" is no, and making it yes is a recorded act by something outside the recursion.
The other mistake we made was capability inheritance. Our first pass copied the parent's capability set to every child, which felt like the safe, boring option — the child can do exactly what the parent can, so there's no escalation. It escalates by multiplication instead. A parent holding orders.write with a fan-out of 4 becomes four writers, and you've turned a concurrency knob into a side-effect multiplier. What we do now: the child's capability set is computed as an intersection with the parent's at spawn time, write scopes are granted by name per lease rather than inherited, and registering an MCP server is never inherited at all. That one is a human decision made once, at the edge, with the server's origin and the tools it exposes written down. Reads travel with the family. Writes get introduced.
Memory was the piece that actually scared us. In MCP terms, agents write to memory namespaces, and we'd given each agent its own namespace and let it write freely, which sounds contained. It isn't, because reads are shared. The next agent in the chain reads the previous agent's notes as trusted context. We had a summarizer that got handed a record from an upstream source, wrote a follow-up instruction into its own namespace, and the planner picked that up as a directive. Nothing crashed. No alert fired. The planner just started doing something nobody had asked for, and tracing it back three hops upstream took most of a morning. Now writes are namespaced to the lease, promotion into a shared namespace is its own capability, and every promoted note carries the lease id that produced it. A bad note has a return address.
Which brings me back to the reason this post exists. The choice between locking everything down and letting it run is a false one, and we only saw that after burning a week on the locked-down side. What replaced the static allowlist was a runtime grant path, and the same path is what makes recursive spawning survivable. When an agent's lease doesn't cover what it's trying to do, it opens a scoped request against that lease — the capability, the reason, a pointer to the work that needs it. Whoever is on call sees it in the same channel as everything else. Approve, deny, and either way the grant expires fifteen minutes later. If the same request shows up five times in one day, that's not an emergency. That's a signal that a lease template is wrong, and we fix the template with a diff and an owner, on purpose, instead of finding out when a loop has already widened itself.
The honest tradeoff: the approval path costs latency at exactly the moment an agent is trying to make progress, and agents are bad at waiting gracefully. We tried auto-approving requests that repeated, on the theory that a loop asking for the same thing five times is probably legitimate. That turned into privilege creep with extra steps — a persistent enough agent gets what it wants by asking, and the log fills with approvals nobody read. So a person stays in the loop for anything that writes, spawns, or registers a server, and read scopes move on their own. That split is a guess that's held up for a few months. I'd be lying if I said we've watched it hold against an agent actively hunting for the gap.
Back to that line from night three. The list wasn't wrong; it was frozen, and the business had moved out from under it. A frozen boundary in a system that changes weekly doesn't fail safely — it fails in whichever direction is more annoying, denying good work until somebody gets paged, and then eventually getting widened at 3 in the morning by whoever wants the paging to stop. That's the path from a strict allowlist to an open door, and it's the path most recursive agent stacks are already on without noticing.
So before you argue about how deep your spawn tree should go, ask the smaller question: can your permission boundary move when the business changes? If it can't, some future release will move it for you. If those agents can spawn other agents, that release moves it for you everywhere at once, at a depth nobody chose.
來源:DEV Community · dev.to