A RuleSet is consulted before -yolo, the read-only list and anything the session remembers: a deny holds in every mode, an ask forces a prompt the mode would have skipped, an allow skips it. SimpleCommands splits a script into its simple commands so a prefix rule can be matched one command at a time, and the ACP floor no longer settles a prompt an ask rule forced. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 6 changed files with 437 additions and 2 deletions.
| @@ -36,6 +36,11 @@ type Request struct { | ||
| 36 | 36 | // agent. Several agents share one prompt box, so a request that does not |
| 37 | 37 | // say who is asking cannot be decided on. |
| 38 | 38 | Agent string |
| 39 | + // Forced names the standing rule that insisted on this prompt — an ask | |
| 40 | + // rule in a settings file — and is empty for an ordinary one. A front end | |
| 41 | + // that settles some prompts by itself, as an ACP permission floor does, | |
| 42 | + // must not settle these: the rule exists to stop exactly that. | |
| 43 | + Forced string | |
| 39 | 44 | } |
| 40 | 45 | |
| 41 | 46 | // Prompt asks a human. Returning an error aborts the turn. |
| @@ -90,6 +95,13 @@ type Policy struct { | ||
| 90 | 95 | // fired around the prompt rather than around Approve, so a call allowed |
| 91 | 96 | // outright or remembered from earlier says nothing. |
| 92 | 97 | Hooks *hooks.Hooks |
| 98 | + | |
| 99 | + // Rules are standing rules from a settings file, consulted before | |
| 100 | + // anything else: before YOLO, before the read-only list and before the | |
| 101 | + // session's memory. A deny there holds in every mode, which is what makes | |
| 102 | + // it worth writing down; an ask there forces a prompt the mode would have | |
| 103 | + // skipped. Nil means none. | |
| 104 | + Rules RuleSet | |
| 93 | 105 | } |
| 94 | 106 | |
| 95 | 107 | // New builds a policy. |
| @@ -108,14 +120,35 @@ func (p *Policy) Approve(ctx context.Context, tool string, input json.RawMessage | ||
| 108 | 120 | // remembered for the whole session, not per agent: "always allow bash" is a |
| 109 | 121 | // statement about this working tree, and an agent id is not a trust boundary. |
| 110 | 122 | func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.RawMessage) error { |
| 111 | - if p == nil || p.YOLO || readOnly[tool] { | |
| 123 | + if p == nil { | |
| 124 | + return nil | |
| 125 | + } | |
| 126 | + // Standing rules first. A deny is the one thing even -yolo does not | |
| 127 | + // override: somebody wrote it down so that it would hold whatever mode the | |
| 128 | + // session happened to be in. A read-only tool is not exempt either — a | |
| 129 | + // rule against reading .env is about reading. | |
| 130 | + ruling := p.check(tool, input) | |
| 131 | + switch ruling.Verdict { | |
| 132 | + case VerdictDeny: | |
| 133 | + return fmt.Errorf("%s was not run: it is denied by the rule %s. Do not retry it; find another way, or tell the user what you need", tool, ruling.because()) | |
| 134 | + case VerdictAllow: | |
| 135 | + return nil | |
| 136 | + } | |
| 137 | + forced := ruling.Verdict == VerdictAsk | |
| 138 | + if !forced && (p.YOLO || readOnly[tool]) { | |
| 112 | 139 | return nil |
| 113 | 140 | } |
| 114 | 141 | req := Describe(tool, input) |
| 115 | 142 | req.Agent = agent |
| 143 | + if forced { | |
| 144 | + req.Forced = ruling.because() | |
| 145 | + req.Summary += " — asked because of " + req.Forced | |
| 146 | + } | |
| 116 | 147 | |
| 148 | + // An ask rule outranks the session's memory too: "always this tool" given | |
| 149 | + // to an earlier prompt was not an answer to a rule that says always ask. | |
| 117 | 150 | p.mu.Lock() |
| 118 | - allowed := p.tools[tool] || (req.Rememberable && p.commands[memoryKey(tool, req.Detail)]) | |
| 151 | + allowed := !forced && (p.tools[tool] || (req.Rememberable && p.commands[memoryKey(tool, req.Detail)])) | |
| 119 | 152 | p.mu.Unlock() |
| 120 | 153 | if allowed { |
| 121 | 154 | return nil |
| @@ -0,0 +1,73 @@ | ||
| 1 | + package approval | |
| 2 | + | |
| 3 | + import "encoding/json" | |
| 4 | + | |
| 5 | + // Verdict is what a set of standing rules says about one tool call, before | |
| 6 | + // anyone is asked and before anything the session remembers is consulted. | |
| 7 | + type Verdict int | |
| 8 | + | |
| 9 | + const ( | |
| 10 | + // NoVerdict means no rule spoke, and the call is decided the ordinary way: | |
| 11 | + // the mode, the session's memory, and then a prompt. | |
| 12 | + NoVerdict Verdict = iota | |
| 13 | + // VerdictAllow lets the call through without a prompt. | |
| 14 | + VerdictAllow | |
| 15 | + // VerdictAsk puts the call to a human even when the mode would have let | |
| 16 | + // it through, and even when the session remembers allowing it. | |
| 17 | + VerdictAsk | |
| 18 | + // VerdictDeny refuses the call outright, in every mode, -yolo included. | |
| 19 | + VerdictDeny | |
| 20 | + ) | |
| 21 | + | |
| 22 | + // Ruling is a verdict and the rule that produced it, for the message the model | |
| 23 | + // reads and the prompt a human reads. "Denied" with no reason is a dead end; | |
| 24 | + // "denied by Bash(rm:*) in ~/.claude/settings.json" is something to fix. | |
| 25 | + type Ruling struct { | |
| 26 | + Verdict Verdict | |
| 27 | + // Rule is the rule as it was written, e.g. Bash(npm run test:*). | |
| 28 | + Rule string | |
| 29 | + // Source is where the rule was written, e.g. ~/.claude/settings.json. | |
| 30 | + Source string | |
| 31 | + } | |
| 32 | + | |
| 33 | + // RuleSet is a set of standing rules that act before the session's own | |
| 34 | + // memory: rules written down in a settings file rather than given as an | |
| 35 | + // answer to a prompt. Claude Code's permissions.allow, deny and ask are the | |
| 36 | + // ones that exist so far. | |
| 37 | + // | |
| 38 | + // It is an interface so this package stays ignorant of where rules come from | |
| 39 | + // and of the grammar they are written in. Whoever loads them decides which | |
| 40 | + // files are honoured; the policy only acts on what they say. | |
| 41 | + type RuleSet interface { | |
| 42 | + // Check rules on one call. Deny outranks ask, which outranks allow, so a | |
| 43 | + // single rule set can never be argued out of a refusal by a broader | |
| 44 | + // permission elsewhere in it. | |
| 45 | + Check(tool string, input json.RawMessage) Ruling | |
| 46 | + // Lines describe the rules for /allowed, grouped by where they came from. | |
| 47 | + Lines() []string | |
| 48 | + } | |
| 49 | + | |
| 50 | + // check is Rules.Check, tolerating a policy with no rules. | |
| 51 | + func (p *Policy) check(tool string, input json.RawMessage) Ruling { | |
| 52 | + if p.Rules == nil { | |
| 53 | + return Ruling{} | |
| 54 | + } | |
| 55 | + return p.Rules.Check(tool, input) | |
| 56 | + } | |
| 57 | + | |
| 58 | + // RuleLines is what /allowed shows under the session's own memory: the | |
| 59 | + // standing rules, and the files they came from. Nil when there are none. | |
| 60 | + func (p *Policy) RuleLines() []string { | |
| 61 | + if p == nil || p.Rules == nil { | |
| 62 | + return nil | |
| 63 | + } | |
| 64 | + return p.Rules.Lines() | |
| 65 | + } | |
| 66 | + | |
| 67 | + // because names the rule behind a ruling, for a message. | |
| 68 | + func (r Ruling) because() string { | |
| 69 | + if r.Source == "" { | |
| 70 | + return r.Rule | |
| 71 | + } | |
| 72 | + return r.Rule + " in " + r.Source | |
| 73 | + } | |
| @@ -0,0 +1,143 @@ | ||
| 1 | + package approval | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "reflect" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + // fixedRules rules the same way on every call to one tool. | |
| 12 | + type fixedRules struct { | |
| 13 | + tool string | |
| 14 | + verdict Verdict | |
| 15 | + } | |
| 16 | + | |
| 17 | + func (f fixedRules) Check(tool string, _ json.RawMessage) Ruling { | |
| 18 | + if tool != f.tool { | |
| 19 | + return Ruling{} | |
| 20 | + } | |
| 21 | + return Ruling{Verdict: f.verdict, Rule: "Rule(x)", Source: "settings.json"} | |
| 22 | + } | |
| 23 | + | |
| 24 | + func (f fixedRules) Lines() []string { return []string{"settings.json", " rule"} } | |
| 25 | + | |
| 26 | + func TestStandingRules(t *testing.T) { | |
| 27 | + tests := []struct { | |
| 28 | + name string | |
| 29 | + tool string | |
| 30 | + verdict Verdict | |
| 31 | + yolo bool | |
| 32 | + remembered bool | |
| 33 | + wantErr bool | |
| 34 | + wantPrompt int | |
| 35 | + }{ | |
| 36 | + {name: "deny holds under yolo", tool: "bash", verdict: VerdictDeny, yolo: true, wantErr: true}, | |
| 37 | + {name: "deny holds for a read-only tool", tool: "read_file", verdict: VerdictDeny, wantErr: true}, | |
| 38 | + {name: "deny beats session memory", tool: "bash", verdict: VerdictDeny, remembered: true, wantErr: true}, | |
| 39 | + {name: "allow skips the prompt", tool: "bash", verdict: VerdictAllow}, | |
| 40 | + {name: "ask prompts under yolo", tool: "bash", verdict: VerdictAsk, yolo: true, wantPrompt: 1}, | |
| 41 | + {name: "ask prompts for a read-only tool", tool: "read_file", verdict: VerdictAsk, wantPrompt: 1}, | |
| 42 | + {name: "ask beats session memory", tool: "bash", verdict: VerdictAsk, remembered: true, wantPrompt: 1}, | |
| 43 | + {name: "no verdict prompts as usual", tool: "bash", verdict: NoVerdict, wantPrompt: 1}, | |
| 44 | + {name: "no verdict still honours memory", tool: "bash", verdict: NoVerdict, remembered: true}, | |
| 45 | + } | |
| 46 | + for _, tc := range tests { | |
| 47 | + t.Run(tc.name, func(t *testing.T) { | |
| 48 | + var asked int | |
| 49 | + var forced string | |
| 50 | + p := New(func(_ context.Context, r Request) (Decision, error) { | |
| 51 | + asked++ | |
| 52 | + forced = r.Forced | |
| 53 | + return Once, nil | |
| 54 | + }) | |
| 55 | + p.YOLO = tc.yolo | |
| 56 | + p.Rules = fixedRules{tool: tc.tool, verdict: tc.verdict} | |
| 57 | + if tc.remembered { | |
| 58 | + p.tools[tc.tool] = true | |
| 59 | + } | |
| 60 | + err := approve(t, p, tc.tool, `{"command":"ls","path":"a"}`) | |
| 61 | + if (err != nil) != tc.wantErr { | |
| 62 | + t.Fatalf("err = %v, want error %v", err, tc.wantErr) | |
| 63 | + } | |
| 64 | + if err != nil && !strings.Contains(err.Error(), "Rule(x) in settings.json") { | |
| 65 | + t.Errorf("denial %q should name the rule and its file", err) | |
| 66 | + } | |
| 67 | + if asked != tc.wantPrompt { | |
| 68 | + t.Errorf("prompted %d times, want %d", asked, tc.wantPrompt) | |
| 69 | + } | |
| 70 | + if tc.verdict == VerdictAsk && forced == "" { | |
| 71 | + t.Error("a prompt an ask rule forced should say so") | |
| 72 | + } | |
| 73 | + }) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + // Headless, an ask rule is a refusal with nobody to put the question to — | |
| 78 | + // which is what the rule asked for. | |
| 79 | + func TestAskRuleHeadlessDenies(t *testing.T) { | |
| 80 | + p := New(nil) | |
| 81 | + p.YOLO = true | |
| 82 | + p.Rules = fixedRules{tool: "bash", verdict: VerdictAsk} | |
| 83 | + if err := approve(t, p, "bash", `{"command":"ls"}`); err == nil { | |
| 84 | + t.Error("an ask rule with no prompt should not run the call") | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + func TestRuleLines(t *testing.T) { | |
| 89 | + var p *Policy | |
| 90 | + if p.RuleLines() != nil { | |
| 91 | + t.Error("nil policy") | |
| 92 | + } | |
| 93 | + p = New(nil) | |
| 94 | + if p.RuleLines() != nil { | |
| 95 | + t.Error("no rules") | |
| 96 | + } | |
| 97 | + p.Rules = fixedRules{} | |
| 98 | + if len(p.RuleLines()) != 2 { | |
| 99 | + t.Error("lines not passed through") | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + func TestSimpleCommands(t *testing.T) { | |
| 104 | + tests := []struct { | |
| 105 | + script string | |
| 106 | + want []string | |
| 107 | + pinned []bool | |
| 108 | + opaque bool | |
| 109 | + }{ | |
| 110 | + {"git status", []string{"git status"}, []bool{true}, false}, | |
| 111 | + {"go build ./... && go test ./...", []string{"go build ./...", "go test ./..."}, []bool{true, true}, false}, | |
| 112 | + {"ls | grep x; echo done", []string{"ls", "grep x", "echo done"}, []bool{true, true, true}, false}, | |
| 113 | + {"(cd sub && make)", []string{"cd sub", "make"}, []bool{true, true}, false}, | |
| 114 | + {`git commit -m "fix: the thing"`, []string{"git commit -m fix: the thing"}, []bool{true}, false}, | |
| 115 | + {"rm $FILE", []string{"rm <expanded>"}, []bool{true}, false}, | |
| 116 | + {"ls *.go", []string{"ls <expanded>"}, []bool{true}, false}, | |
| 117 | + {"FOO=1 make", []string{"make"}, []bool{false}, false}, | |
| 118 | + {"echo x > out.txt", []string{"echo x"}, []bool{false}, false}, | |
| 119 | + {"echo x >> out.txt", []string{"echo x"}, []bool{false}, false}, | |
| 120 | + {"echo x 2>&1 >/dev/null", []string{"echo x"}, []bool{true}, false}, | |
| 121 | + {"export A=1", []string{"export"}, []bool{false}, false}, | |
| 122 | + {"echo $(whoami)", []string{"echo <expanded>", "whoami"}, []bool{true, true}, true}, | |
| 123 | + {"diff <(ls a) <(ls b)", []string{"diff <expanded> <expanded>", "ls a", "ls b"}, []bool{true, true, true}, true}, | |
| 124 | + } | |
| 125 | + for _, tc := range tests { | |
| 126 | + cmds, info := SimpleCommands(tc.script) | |
| 127 | + var got []string | |
| 128 | + var pinned []bool | |
| 129 | + for _, c := range cmds { | |
| 130 | + got = append(got, c.Text()) | |
| 131 | + pinned = append(pinned, c.Pinned) | |
| 132 | + } | |
| 133 | + if !reflect.DeepEqual(got, tc.want) || !reflect.DeepEqual(pinned, tc.pinned) { | |
| 134 | + t.Errorf("%q: got %q %v, want %q %v", tc.script, got, pinned, tc.want, tc.pinned) | |
| 135 | + } | |
| 136 | + if info.Opaque != tc.opaque { | |
| 137 | + t.Errorf("%q: opaque %v, want %v", tc.script, info.Opaque, tc.opaque) | |
| 138 | + } | |
| 139 | + } | |
| 140 | + if cmds, info := SimpleCommands("echo 'open"); cmds != nil || info.ParseError == nil { | |
| 141 | + t.Error("a parse error should yield nothing") | |
| 142 | + } | |
| 143 | + } | |
| @@ -0,0 +1,158 @@ | ||
| 1 | + package approval | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "strings" | |
| 5 | + | |
| 6 | + "mvdan.cc/sh/v3/syntax" | |
| 7 | + ) | |
| 8 | + | |
| 9 | + // SimpleCommand is one command a shell script runs, as its words. | |
| 10 | + // | |
| 11 | + // It exists for rules written as command prefixes — Claude Code's | |
| 12 | + // Bash(npm run test:*) — which can only be matched honestly one simple command | |
| 13 | + // at a time. Matched against the whole line, "npm run test && rm -rf ~" starts | |
| 14 | + // with "npm run test", and an allow rule written for the first half would | |
| 15 | + // quietly allow the second. | |
| 16 | + type SimpleCommand struct { | |
| 17 | + // Words are the command's words after quote removal: "git", "commit", | |
| 18 | + // "-m", "fix it". A word the shell would build at run time — a variable, | |
| 19 | + // a glob, a backslash escape this reader does not undo — is left empty, | |
| 20 | + // and Literal says so. | |
| 21 | + Words []string | |
| 22 | + // Literal is parallel to Words: true where the word is fixed text. | |
| 23 | + Literal []bool | |
| 24 | + // Pinned is false when something about this command is decided by more | |
| 25 | + // than its words: a variable assignment in front of it, which can change | |
| 26 | + // what the name resolves to (PATH=. git), or an output redirection to a | |
| 27 | + // file, which writes somewhere no prefix of the words mentions. | |
| 28 | + Pinned bool | |
| 29 | + } | |
| 30 | + | |
| 31 | + // Text is the command as words joined by single spaces, with a placeholder | |
| 32 | + // where a word is not fixed. It is for messages, not for matching. | |
| 33 | + func (c SimpleCommand) Text() string { | |
| 34 | + out := make([]string, len(c.Words)) | |
| 35 | + for i, w := range c.Words { | |
| 36 | + if !c.Literal[i] { | |
| 37 | + w = "<expanded>" | |
| 38 | + } | |
| 39 | + out[i] = w | |
| 40 | + } | |
| 41 | + return strings.Join(out, " ") | |
| 42 | + } | |
| 43 | + | |
| 44 | + // SimpleCommands splits a script into every simple command it contains, | |
| 45 | + // however they are joined: ;, &&, ||, pipes, subshells, the bodies of loops, | |
| 46 | + // conditionals and functions. The ShellInfo is Analyze's for the same script; | |
| 47 | + // when it is opaque, the list is what could be seen and not necessarily | |
| 48 | + // everything that will run, so a caller deciding to allow something must | |
| 49 | + // treat an opaque script as matching nothing. | |
| 50 | + func SimpleCommands(script string) ([]SimpleCommand, ShellInfo) { | |
| 51 | + info := Analyze(script) | |
| 52 | + if info.ParseError != nil { | |
| 53 | + return nil, info | |
| 54 | + } | |
| 55 | + f, err := syntax.NewParser().Parse(strings.NewReader(script), "") | |
| 56 | + if err != nil { | |
| 57 | + return nil, info | |
| 58 | + } | |
| 59 | + var out []SimpleCommand | |
| 60 | + syntax.Walk(f, func(node syntax.Node) bool { | |
| 61 | + switch n := node.(type) { | |
| 62 | + case *syntax.ProcSubst: | |
| 63 | + // <(cmd) runs cmd as surely as $(cmd) does, and Analyze does not | |
| 64 | + // look for it, so it is marked here. | |
| 65 | + info.Opaque, info.Reason = true, "it contains process substitution, so what runs is decided at run time" | |
| 66 | + case *syntax.Stmt: | |
| 67 | + call, ok := n.Cmd.(*syntax.CallExpr) | |
| 68 | + if ok && len(call.Args) > 0 { | |
| 69 | + out = append(out, simple(call, n.Redirs)) | |
| 70 | + return true | |
| 71 | + } | |
| 72 | + if decl, ok := n.Cmd.(*syntax.DeclClause); ok { | |
| 73 | + // export, local, declare: a builtin with assignments for | |
| 74 | + // arguments. Kept as a command so a rule must account for it, | |
| 75 | + // never pinned, because an assignment is what it does. | |
| 76 | + out = append(out, SimpleCommand{Words: []string{decl.Variant.Value}, Literal: []bool{true}}) | |
| 77 | + } | |
| 78 | + } | |
| 79 | + return true | |
| 80 | + }) | |
| 81 | + return out, info | |
| 82 | + } | |
| 83 | + | |
| 84 | + func simple(call *syntax.CallExpr, redirs []*syntax.Redirect) SimpleCommand { | |
| 85 | + c := SimpleCommand{Pinned: len(call.Assigns) == 0} | |
| 86 | + for _, w := range call.Args { | |
| 87 | + text, ok := unquote(w) | |
| 88 | + c.Words = append(c.Words, text) | |
| 89 | + c.Literal = append(c.Literal, ok) | |
| 90 | + } | |
| 91 | + for _, r := range redirs { | |
| 92 | + if writesFile(r) { | |
| 93 | + c.Pinned = false | |
| 94 | + } | |
| 95 | + } | |
| 96 | + return c | |
| 97 | + } | |
| 98 | + | |
| 99 | + // unquote returns a word's text after quote removal when the text is fixed, | |
| 100 | + // and false when any part of it is expanded at run time. | |
| 101 | + func unquote(w *syntax.Word) (string, bool) { | |
| 102 | + var b strings.Builder | |
| 103 | + for _, part := range w.Parts { | |
| 104 | + switch p := part.(type) { | |
| 105 | + case *syntax.Lit: | |
| 106 | + // A backslash in an unquoted literal is an escape the shell will | |
| 107 | + // remove; rather than reimplement that, the word is not fixed. | |
| 108 | + // Globs and a leading ~ are expanded by the shell, so they are | |
| 109 | + // not fixed either. | |
| 110 | + if strings.ContainsAny(p.Value, "\\*?[") || (b.Len() == 0 && strings.HasPrefix(p.Value, "~")) { | |
| 111 | + return "", false | |
| 112 | + } | |
| 113 | + b.WriteString(p.Value) | |
| 114 | + case *syntax.SglQuoted: | |
| 115 | + if p.Dollar { | |
| 116 | + return "", false | |
| 117 | + } | |
| 118 | + b.WriteString(p.Value) | |
| 119 | + case *syntax.DblQuoted: | |
| 120 | + for _, inner := range p.Parts { | |
| 121 | + lit, ok := inner.(*syntax.Lit) | |
| 122 | + if !ok || strings.Contains(lit.Value, "\\") { | |
| 123 | + return "", false | |
| 124 | + } | |
| 125 | + b.WriteString(lit.Value) | |
| 126 | + } | |
| 127 | + default: | |
| 128 | + return "", false | |
| 129 | + } | |
| 130 | + } | |
| 131 | + return b.String(), true | |
| 132 | + } | |
| 133 | + | |
| 134 | + // writesFile reports whether a redirection writes to a file. Duplicating a | |
| 135 | + // descriptor (2>&1) and throwing output away (>/dev/null) do not. | |
| 136 | + func writesFile(r *syntax.Redirect) bool { | |
| 137 | + switch r.Op { | |
| 138 | + case syntax.RdrIn, syntax.Hdoc, syntax.DashHdoc, syntax.WordHdoc, syntax.DplIn: | |
| 139 | + return false | |
| 140 | + case syntax.DplOut: | |
| 141 | + target, ok := unquote(r.Word) | |
| 142 | + return !ok || !(target == "-" || isDigits(target)) | |
| 143 | + } | |
| 144 | + target, ok := unquote(r.Word) | |
| 145 | + return !ok || target != "/dev/null" | |
| 146 | + } | |
| 147 | + | |
| 148 | + func isDigits(s string) bool { | |
| 149 | + if s == "" { | |
| 150 | + return false | |
| 151 | + } | |
| 152 | + for _, r := range s { | |
| 153 | + if r < '0' || r > '9' { | |
| 154 | + return false | |
| 155 | + } | |
| 156 | + } | |
| 157 | + return true | |
| 158 | + } | |
| @@ -0,0 +1,23 @@ | ||
| 1 | + package serve | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "testing" | |
| 5 | + | |
| 6 | + "github.com/johannSo/codey/internal/approval" | |
| 7 | + ) | |
| 8 | + | |
| 9 | + // A prompt a standing ask rule insisted on is put to the client whatever the | |
| 10 | + // floor: the rule exists to stop a mode from settling it. | |
| 11 | + func TestTheFloorDoesNotSettleAForcedPrompt(t *testing.T) { | |
| 12 | + edit := approval.Request{Tool: "edit_file", Rememberable: true} | |
| 13 | + forced := edit | |
| 14 | + forced.Forced = "Edit(*.go) in .claude/settings.json" | |
| 15 | + for _, mode := range []PermissionMode{ModeAcceptEdits, ModeAuto, ModeFullAccess} { | |
| 16 | + if !mode.allows(edit) { | |
| 17 | + t.Errorf("%s should settle an ordinary edit", mode) | |
| 18 | + } | |
| 19 | + if mode.allows(forced) { | |
| 20 | + t.Errorf("%s settled a prompt an ask rule forced", mode) | |
| 21 | + } | |
| 22 | + } | |
| 23 | + } | |
| @@ -55,6 +55,11 @@ const ( | ||
| 55 | 55 | |
| 56 | 56 | // allows reports whether the mode settles this call without asking. |
| 57 | 57 | func (m PermissionMode) allows(req approval.Request) bool { |
| 58 | + // A prompt a standing ask rule insisted on is never settled by the floor: | |
| 59 | + // the rule was written to stop exactly that. | |
| 60 | + if req.Forced != "" { | |
| 61 | + return false | |
| 62 | + } | |
| 58 | 63 | switch m { |
| 59 | 64 | case ModeFullAccess: |
| 60 | 65 | return true |