Vectrum
Sign In

approval: ask on behalf of a named agent, and describe a spawn

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>
Jjonicommitted 1 parent cb063cecommit8db39ad

Showing 2 changed files with 120 additions and 0 deletions.

+45−0internal/approval/policy.goView File
@@ -30,6 +30,10 @@ type Request struct {
3030 // Rememberable is false when "always allow this exact command" would be a
3131 // lie — an opaque shell command does not determine what it runs.
3232 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
3337 }
3438
3539 // Prompt asks a human. Returning an error aborts the turn.
@@ -68,10 +72,18 @@ func New(prompt Prompt) *Policy {
6872 // text the model reads, so a denial tells it to change course rather than
6973 // looking like a tool that broke.
7074 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 {
7182 if p == nil || p.YOLO || readOnly[tool] {
7283 return nil
7384 }
7485 req := describe(tool, input)
86+ req.Agent = agent
7587
7688 p.mu.Lock()
7789 allowed := p.tools[tool] || (req.Rememberable && p.commands[memoryKey(tool, req.Detail)])
@@ -126,6 +138,19 @@ func (p *Policy) Remembered() []string {
126138
127139 func memoryKey(tool, detail string) string { return tool + ": " + detail }
128140
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+
129154 // describe turns a tool call into something a human can decide on in one read.
130155 func describe(tool string, input json.RawMessage) Request {
131156 var args struct {
@@ -134,6 +159,10 @@ func describe(tool string, input json.RawMessage) Request {
134159 OldStr string `json:"old_str"`
135160 NewStr string `json:"new_str"`
136161 Content string `json:"content"`
162+ Preset string `json:"preset"`
163+ Task string `json:"task"`
164+ Model string `json:"model"`
165+ Effort string `json:"effort"`
137166 }
138167 json.Unmarshal(input, &args)
139168
@@ -153,6 +182,22 @@ func describe(tool string, input json.RawMessage) Request {
153182 Detail: args.Path,
154183 Rememberable: true,
155184 }
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+ }
156201 case "write_file":
157202 return Request{
158203 Tool: tool,
+75−0internal/approval/policy_test.goView File
@@ -156,3 +156,78 @@ func TestRemembered(t *testing.T) {
156156 t.Errorf("Remembered() = %v", got)
157157 }
158158 }
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+ }