Chapter 3 of 7 · 9 min read
A narrow tool set bounds what a delegated unit can do
On this page
Ardwick Health is collapsing two VPCs after an acquisition — virtual private clouds, the isolated networks its services run inside — and the subnet 10.12.0.0/20 in eu-west-1a is being retired at the end of the month. That address with a slash after it is a CIDR block, the standard notation for a contiguous range of IP addresses, and it is the string everything in this chapter is searching for. Before anything is deleted somebody has to know which Terraform modules still reference it, Terraform being the tool that declares cloud infrastructure in files and reconciles the real accounts against those declarations, across a repository with 61 modules, four environments, and a habit of passing subnet IDs down through three layers of module variables. The platform team's agent handles requests like this, and its tool set is the one the job needs: terraform plan, which works out what a change would do, terraform apply, which carries it out and updates the state file recording which real cloud resources correspond to which declarations, file read, file write, and a shell.
The delegation is obvious. It is a search over a lot of HCL — HashiCorp Configuration Language, the declarative syntax Terraform files are written in — producing a short list, the parent is mid-way through a change request and does not want 61 modules in its window, and the answer is a handful of module paths. The question that gets skipped is which of those five tools the child inherits, and the default answer in most implementations is all of them, because the child is the same runtime and copying the parent's grant is the path of least configuration.
The grant is the only thing between a misreading and an action
Here is the whole difference, in the frontmatter.
---
name: subnet-reference-finder
description: Finds Terraform modules that reference a given subnet CIDR
or subnet ID, directly or through a variable.
tools: Read, Grep, Glob
---
You are given a CIDR block and a repository root. Find every module
that references it, including references that arrive through a
variable defined elsewhere in the repo.
Return ONLY a fenced JSON array of:
{ "module": "<path to the module directory>",
"file": "<repo-relative path>",
"line": <integer>,
"via": "literal" | "variable:<name>" | "data_source:<address>" }
Do not attempt to verify a reference by running Terraform. If you
cannot resolve a variable to a value from the files alone, report the
reference with via: "variable:<name>" and stop there.The reasoning behind that list is short. The task's output is a set of file references, and every step that produces them is a read: glob the module directories, grep for the CIDR and for the subnet ID, open the files where the value is passed as a variable, follow the variable to its definition. Nothing in that sequence writes a file, mutates infrastructure, or needs a shell. Where a task's write requirement is zero, the grant should be zero, because the grant is the only mechanism that makes that fact true at runtime rather than aspirationally.
| Parent holds | Child gets | Why |
|---|---|---|
terraform apply | no | mutates real infrastructure; task has no mutation step |
terraform plan | no | needs credentials, refreshes state, takes a state lock |
| file write | no | output is a list, delivered through the return value |
| shell | no | the widest grant there is, and no step needs it |
| file read / grep / glob | yes | this is the task |
The teammate's objection is worth taking seriously rather than waving away, because it is not stupid. The child was told to find references; a model that has been told to find references will not decide to run apply; restricting it is configuration that will get in the way the first time the task turns out to need something. Each of those clauses is plausible and the conclusion is still wrong, for two reasons that have nothing to do with how likely the model is to misbehave on purpose.
The first is visibility. A delegated unit is the component you can see least of while it runs, by construction — the entire point of the boundary is that its transcript does not enter the parent, so what you observe is a final message and, if you are logging well, a list of tool calls after the fact. When a long inline session does something surprising, the surprise sits in the transcript in front of you and you can interrupt it. A child's twelve tool calls happen inside eleven seconds behind a boundary designed to hide them.
The second is that the task description is not the only text in the child's window. The files are input, and files contain sentences. Ardwick's repository has a module README that opens with a runbook: this subnet is managed manually; to move it, run terraform apply -target=module.legacy_net -auto-approve from the ops account. That is an instruction-shaped string arriving in the window of a model whose job is to read instructions and act on them, and no amount of care in the delegating prompt changes the fact that the sentence is there. With the grant above it is inert text that the child reports or ignores. With apply in the grant it is a live suggestion, and the difference between those two outcomes is one line of YAML against an unattended change to production, which is not a close trade in either direction.
It is also worth noticing what the restriction costs when the objection is right and the task does turn out to need something. A blocked read fails immediately, visibly, and with a message naming the tool that was denied, which takes a minute to diagnose and a line to fix. Ardwick's logs over three months show eleven denials across roughly four hundred delegations, of which nine were the child reaching for a tool it did not need and two were genuine gaps in the grant. That is the shape of the asymmetry: the failure mode of being too narrow is a stack of cheap, legible interruptions, and the failure mode of being too wide is a single event you find out about from a state file.
There is a third argument that only shows up after an incident. A narrow grant makes destructive actions attributable. If every subagent that can write is one of two named definitions, then a bad write is traceable to a bounded task with a brief you can read — the brief being the instructions the parent composed for that one delegation, and the only channel through which anything the parent knows reaches the child — rather than to turn 340 of a shared session in which the agent had held write access continuously for ninety minutes. Attribution is what lets you fix the class of problem instead of apologising for the instance.
What tool restriction is not
It is not a sandbox, and treating it as one is how teams end up with a false sense of the boundary. The child runs in the same process, on the same machine, with the same environment variables and the same AWS credentials as the parent. The restriction is enforced by the agent runtime refusing to expose certain tools to that turn, which is a real and useful control, and it is a control at the wrong layer to defend against anything that gets underneath the tool interface. A Read grant on a machine whose home directory contains ~/.aws/credentials is a grant that can read those credentials, and a contract asking the child to return file contents is a channel for getting them out.
The layer that does bound this properly is the operating system: run the child in a container with a read-only bind mount of the repository, no network, and no credentials in its environment. That costs startup time — Ardwick measured about 900 ms of container start per delegation — and it is the right cost for children that touch anything untrusted. Tool restriction and process isolation solve overlapping problems at different prices, and the useful way to hold them is that a tool grant bounds a cooperative child that might be confused, while a sandbox bounds a child whose inputs might be hostile.
The state of the art is currently somewhere between the two, in argument-level permissions: grants written not as Bash but as Bash(terraform show -json:*), matching on the command's shape rather than on the tool's name. That is genuinely more expressive and it is also the part of the model that is hardest to get right, because shell syntax is adversarial — a rule matching a prefix has to reason about ;, &&, command substitution, and a dozen ways to spell the same invocation. Use argument-level rules where they let you avoid granting a shell at all, and do not lean on them as if they were a parser.
When the restriction bites
Ardwick's child came back after four minutes with 23 references, of which nine were via: "variable:private_subnet_ids" — resolvable only by knowing what that variable evaluates to in each environment, which is decided by a data "aws_subnets" lookup at plan time. The child's report ended with a suggestion: give it terraform plan and it can resolve them.
The reflex to resist is granting it. plan is often described as the safe half of Terraform, and it is safe with respect to your infrastructure and not with respect to anything else. It authenticates with real credentials, refreshes state against live APIs, acquires the state lock that stops two runs from writing state at once — a row in DynamoDB, Amazon's key-value store, in Ardwick's setup, which a crashed child holds until someone force-unlocks it — writes to .terraform/ and can modify the dependency lock file, and executes provider plugin code. It also reads the state file, and state contains resolved attribute values, which for two of Ardwick's modules includes the master password of an RDS instance — Amazon's managed relational database service — in plaintext. Granting plan to answer a question about variable resolution puts credentials, locks and secrets inside the boundary you have the least visibility into, to save the parent one command.
The parent can run that command itself, once, and pass the answer down.
# In the parent, which already has plan and credentials.
terraform plan -refresh=false -out=/tmp/eu-west-1.tfplan >/dev/null
terraform show -json /tmp/eu-west-1.tfplan \
| jq '.planned_values.root_module | .. | .values? // empty
| select(has("subnet_ids"))' > /tmp/resolved-subnets.json{
"environment": "prod-eu-west-1",
"private_subnet_ids": {
"subnet-0a91c3e2": "10.12.0.0/20",
"subnet-0b47f81d": "10.12.16.0/20",
"subnet-0c22ae90": "10.30.0.0/20"
}
}Those thirty lines go into the child's brief as established fact, the child re-runs with the same read-only grant, and the nine unresolved references collapse to four real ones in three modules. The delegation kept its boundary, the credentialed operation happened in the component with the credentials, and the child's tool set did not change.
Two other moves are available when passing values down is impractical. You can grant the narrower capability the child was actually blocked on rather than the tool it asked for — here, if the parent had produced the plan file already, a Bash(terraform show -json /tmp/eu-west-1.tfplan) grant reads a static artifact with no credentials, no lock and no network, and answers the same question. Or you can split the delegation, so that a resolution step with a wider grant runs as its own small child with its own brief and its own contract, keeping the wide grant attached to a task of six lines rather than to a search over 61 modules. What all three have in common is that the blocked capability gets satisfied at the smallest scope that satisfies it, and none of them are the default reflex, which is to copy the parent's grant and stop thinking about it.
The child in this chapter was safe and it was also ignorant, in a way the tool grant has nothing to say about. It did not know that 10.30.0.0/20 had already been migrated in a change request the parent processed forty turns earlier, so it reported a reference that the parent knew was dead. That kind of loss has nothing to do with permissions and everything to do with the fresh window itself.
Subagents — read the current version at https://daviddominguez.dev/learn/en/subagents/a-narrow-tool-set-bounds-what-a-delegated-unit-can-do