With subagents there can be four things wanting to run a shell command, and "bash wants to run rm -rf build" is not a question anyone can answer without knowing whose it is. ApproveAs carries the agent id into the request; what is remembered stays session-wide, because "always allow bash" is a statement about this working tree and an agent id is not a trust boundary. describe learns spawn_agent: the preset, the model and the effort it will run at, with the task as the detail. It is not rememberable by exact task — no two spawns share one — but the tool-wide answer still applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 2 changed files with 120 additions and 0 deletions.
| @@ -30,6 +30,10 @@ type Request struct { | ||
| 30 | 30 | // Rememberable is false when "always allow this exact command" would be a |
| 31 | 31 | // lie — an opaque shell command does not determine what it runs. |
| 32 | 32 | Rememberable bool |
| 33 | + // Agent is the subagent that wants this, empty for the session's own | |
| 34 | + // agent. Several agents share one prompt box, so a request that does not | |
| 35 | + // say who is asking cannot be decided on. | |
| 36 | + Agent string | |
| 33 | 37 | } |
| 34 | 38 | |
| 35 | 39 | // Prompt asks a human. Returning an error aborts the turn. |
| @@ -68,10 +72,18 @@ func New(prompt Prompt) *Policy { | ||
| 68 | 72 | // text the model reads, so a denial tells it to change course rather than |
| 69 | 73 | // looking like a tool that broke. |
| 70 | 74 | func (p *Policy) Approve(ctx context.Context, tool string, input json.RawMessage) error { |
| 75 | + return p.ApproveAs(ctx, "", tool, input) | |
| 76 | + } | |
| 77 | + | |
| 78 | + // ApproveAs is Approve on behalf of a named subagent. What is remembered is | |
| 79 | + // remembered for the whole session, not per agent: "always allow bash" is a | |
| 80 | + // statement about this working tree, and an agent id is not a trust boundary. | |
| 81 | + func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.RawMessage) error { | |
| 71 | 82 | if p == nil || p.YOLO || readOnly[tool] { |
| 72 | 83 | return nil |
| 73 | 84 | } |
| 74 | 85 | req := describe(tool, input) |
| 86 | + req.Agent = agent | |
| 75 | 87 | |
| 76 | 88 | p.mu.Lock() |
| 77 | 89 | allowed := p.tools[tool] || (req.Rememberable && p.commands[memoryKey(tool, req.Detail)]) |
| @@ -126,6 +138,19 @@ func (p *Policy) Remembered() []string { | ||
| 126 | 138 | |
| 127 | 139 | func memoryKey(tool, detail string) string { return tool + ": " + detail } |
| 128 | 140 | |
| 141 | + // article picks "a" or "an" for a word, so the approval prompt does not ask | |
| 142 | + // about "a explore agent". | |
| 143 | + func article(word string) string { | |
| 144 | + if word == "" { | |
| 145 | + return "a" | |
| 146 | + } | |
| 147 | + switch word[0] { | |
| 148 | + case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U': | |
| 149 | + return "an" | |
| 150 | + } | |
| 151 | + return "a" | |
| 152 | + } | |
| 153 | + | |
| 129 | 154 | // describe turns a tool call into something a human can decide on in one read. |
| 130 | 155 | func describe(tool string, input json.RawMessage) Request { |
| 131 | 156 | var args struct { |
| @@ -134,6 +159,10 @@ func describe(tool string, input json.RawMessage) Request { | ||
| 134 | 159 | OldStr string `json:"old_str"` |
| 135 | 160 | NewStr string `json:"new_str"` |
| 136 | 161 | Content string `json:"content"` |
| 162 | + Preset string `json:"preset"` | |
| 163 | + Task string `json:"task"` | |
| 164 | + Model string `json:"model"` | |
| 165 | + Effort string `json:"effort"` | |
| 137 | 166 | } |
| 138 | 167 | json.Unmarshal(input, &args) |
| 139 | 168 | |
| @@ -153,6 +182,22 @@ func describe(tool string, input json.RawMessage) Request { | ||
| 153 | 182 | Detail: args.Path, |
| 154 | 183 | Rememberable: true, |
| 155 | 184 | } |
| 185 | + case "spawn_agent": | |
| 186 | + summary := "start " + article(args.Preset) + " " + args.Preset + " agent" | |
| 187 | + if args.Model != "" { | |
| 188 | + summary += " on " + args.Model | |
| 189 | + } | |
| 190 | + if args.Effort != "" { | |
| 191 | + summary += " at " + args.Effort + " effort" | |
| 192 | + } | |
| 193 | + return Request{ | |
| 194 | + Tool: tool, | |
| 195 | + Summary: summary, | |
| 196 | + Detail: args.Task, | |
| 197 | + // The task is different every time, so remembering one exact | |
| 198 | + // spawn would allow nothing. "Always this tool" still applies. | |
| 199 | + Rememberable: false, | |
| 200 | + } | |
| 156 | 201 | case "write_file": |
| 157 | 202 | return Request{ |
| 158 | 203 | Tool: tool, |
| @@ -156,3 +156,78 @@ func TestRemembered(t *testing.T) { | ||
| 156 | 156 | t.Errorf("Remembered() = %v", got) |
| 157 | 157 | } |
| 158 | 158 | } |
| 159 | + | |
| 160 | + // Subagents share one approval policy and one prompt box, so a request has to | |
| 161 | + // say which agent it came from — otherwise you are approving a shell command | |
| 162 | + // with no idea who asked for it. | |
| 163 | + func TestApproveAsTagsTheRequestWithTheAgent(t *testing.T) { | |
| 164 | + var got []Request | |
| 165 | + p := New(func(_ context.Context, req Request) (Decision, error) { | |
| 166 | + got = append(got, req) | |
| 167 | + return Once, nil | |
| 168 | + }) | |
| 169 | + | |
| 170 | + if err := p.ApproveAs(context.Background(), "a3", "bash", json.RawMessage(`{"command":"ls"}`)); err != nil { | |
| 171 | + t.Fatalf("ApproveAs: %v", err) | |
| 172 | + } | |
| 173 | + if err := p.Approve(context.Background(), "bash", json.RawMessage(`{"command":"ls"}`)); err != nil { | |
| 174 | + t.Fatalf("Approve: %v", err) | |
| 175 | + } | |
| 176 | + | |
| 177 | + if len(got) != 2 { | |
| 178 | + t.Fatalf("prompted %d times, want 2", len(got)) | |
| 179 | + } | |
| 180 | + if got[0].Agent != "a3" { | |
| 181 | + t.Errorf("subagent request Agent = %q, want %q", got[0].Agent, "a3") | |
| 182 | + } | |
| 183 | + if got[1].Agent != "" { | |
| 184 | + t.Errorf("the session's own request Agent = %q, want empty", got[1].Agent) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + // Spawning is worth asking about every time, but the task text is different | |
| 189 | + // every time — so "always allow this exact one" would be a promise about a | |
| 190 | + // string nobody will ever repeat. The tool-wide answer is the one on offer. | |
| 191 | + func TestSpawnAgentIsDescribedByPresetAndIsNotRememberedByTask(t *testing.T) { | |
| 192 | + req := describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find the approval policy","model":"claude-haiku-4-5"}`)) | |
| 193 | + | |
| 194 | + if !strings.Contains(req.Summary, "an explore agent") { | |
| 195 | + t.Errorf("summary %q does not name the preset, with the article that fits it", req.Summary) | |
| 196 | + } | |
| 197 | + if !strings.Contains(req.Summary, "claude-haiku-4-5") { | |
| 198 | + t.Errorf("summary %q does not name the model it will run on", req.Summary) | |
| 199 | + } | |
| 200 | + if req.Detail != "find the approval policy" { | |
| 201 | + t.Errorf("detail = %q, want the task the agent is being given", req.Detail) | |
| 202 | + } | |
| 203 | + if req.Rememberable { | |
| 204 | + t.Error("a spawn is rememberable by exact task, which no two spawns share") | |
| 205 | + } | |
| 206 | + } | |
| 207 | + | |
| 208 | + // A spawn with no model named runs on the session's, and the prompt should say | |
| 209 | + // that rather than invent an id. | |
| 210 | + func TestSpawnAgentWithoutAModelSaysSo(t *testing.T) { | |
| 211 | + req := describe("spawn_agent", json.RawMessage(`{"preset":"plan","task":"design it"}`)) | |
| 212 | + if !strings.Contains(req.Summary, "a plan agent") { | |
| 213 | + t.Errorf("summary %q does not name the preset", req.Summary) | |
| 214 | + } | |
| 215 | + if strings.Contains(req.Summary, "on ") { | |
| 216 | + t.Errorf("summary %q names a model nobody asked for", req.Summary) | |
| 217 | + } | |
| 218 | + } | |
| 219 | + | |
| 220 | + // What a spawn will spend is the model and the effort together, so the box | |
| 221 | + // that asks about it names both. | |
| 222 | + func TestSpawnApprovalNamesTheEffortWhenThereIsOne(t *testing.T) { | |
| 223 | + with := describe("spawn_agent", json.RawMessage( | |
| 224 | + `{"preset":"explore","task":"find it","model":"claude-opus-5","effort":"low"}`)) | |
| 225 | + if !strings.Contains(with.Summary, "low effort") { | |
| 226 | + t.Errorf("summary %q does not say what effort it will run at", with.Summary) | |
| 227 | + } | |
| 228 | + | |
| 229 | + without := describe("spawn_agent", json.RawMessage(`{"preset":"explore","task":"find it"}`)) | |
| 230 | + if strings.Contains(without.Summary, "effort") { | |
| 231 | + t.Errorf("summary %q invents an effort level nobody asked for", without.Summary) | |
| 232 | + } | |
| 233 | + } | |