Vectrum
Sign In

approval: standing rules, ahead of the session's memory

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>
Jjonicommitted 1 parent 219352ecommit42e117e

Showing 6 changed files with 437 additions and 2 deletions.

+35−2internal/approval/policy.goView File
@@ -36,6 +36,11 @@ type Request struct {
3636 // agent. Several agents share one prompt box, so a request that does not
3737 // say who is asking cannot be decided on.
3838 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
3944 }
4045
4146 // Prompt asks a human. Returning an error aborts the turn.
@@ -90,6 +95,13 @@ type Policy struct {
9095 // fired around the prompt rather than around Approve, so a call allowed
9196 // outright or remembered from earlier says nothing.
9297 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
93105 }
94106
95107 // New builds a policy.
@@ -108,14 +120,35 @@ func (p *Policy) Approve(ctx context.Context, tool string, input json.RawMessage
108120 // remembered for the whole session, not per agent: "always allow bash" is a
109121 // statement about this working tree, and an agent id is not a trust boundary.
110122 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]) {
112139 return nil
113140 }
114141 req := Describe(tool, input)
115142 req.Agent = agent
143+ if forced {
144+ req.Forced = ruling.because()
145+ req.Summary += " — asked because of " + req.Forced
146+ }
116147
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.
117150 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)]))
119152 p.mu.Unlock()
120153 if allowed {
121154 return nil
+73−0internal/approval/ruleset.goaddedView File
@@ -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+ }
+143−0internal/approval/ruleset_test.goaddedView File
@@ -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+ }
+158−0internal/approval/simple.goaddedView File
@@ -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+ }
+23−0internal/serve/floor_test.goaddedView File
@@ -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+ }
+5−0internal/serve/serve.goView File
@@ -55,6 +55,11 @@ const (
5555
5656 // allows reports whether the mode settles this call without asking.
5757 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+ }
5863 switch m {
5964 case ModeFullAccess:
6065 return true