Vectrum
Sign In

ccsettings: Claude Code's servers and rules, and who may grant what

Reads .mcp.json, ~/.claude.json and the three settings files. Deny and ask
rules count from every file; allow rules and .mcp.json servers that came
from the repository wait for a trust decision recorded outside it, against
a fingerprint of the exact definitions. match.go is a small stand-in rule
matcher, to be swapped for the shared parser at merge.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 42e117ecommit3944649

Showing 9 changed files with 1884 additions and 0 deletions.

+96−0internal/ccsettings/ccsettings.goaddedView File
@@ -0,0 +1,96 @@
1+ // Package ccsettings reads the configuration Claude Code keeps for a user and
2+ // for a project, so that someone who already set Claude Code up does not have
3+ // to set Codey up a second time: the MCP servers it knows about, the
4+ // permission rules in its settings files, and the CLAUDE.md files it loads
5+ // into every conversation.
6+ //
7+ // Reading is the easy half. The half that needs care is what is honoured, and
8+ // it turns on who wrote each file. ~/.claude.json and ~/.claude/settings.json
9+ // are the user's own, written by the user or by `claude` on their behalf, and
10+ // they carry the same weight as Codey's own config. A project's .mcp.json and
11+ // .claude/settings.json are committed to the repository, which means they were
12+ // written by whoever wrote the repository: starting a server listed there is
13+ // running a command a stranger chose, and an allow rule there is a stranger
14+ // deciding what runs without asking. Those two are honoured only once the user
15+ // has said, for this checkout and these exact definitions, that they trust
16+ // them — the same bargain Claude Code's own trust dialog strikes. Anything
17+ // that can only take power away — a deny rule, an ask rule — is honoured from
18+ // every file, because nobody is harmed by an agent that asks more often.
19+ //
20+ // Nothing here writes to Claude Code's files. The one file this package
21+ // writes is Codey's own record of those trust decisions.
22+ package ccsettings
23+
24+ import (
25+ "encoding/json"
26+ "errors"
27+ "fmt"
28+ "io/fs"
29+ "os"
30+ "path/filepath"
31+ "strings"
32+ )
33+
34+ // Scope is who a setting belongs to, which is what decides whether it is
35+ // honoured without asking.
36+ type Scope string
37+
38+ const (
39+ // ScopeUser is the user's own, for every project: ~/.claude/settings.json,
40+ // and the top-level servers in ~/.claude.json.
41+ ScopeUser Scope = "user"
42+ // ScopeProject is committed to the repository: .mcp.json and
43+ // .claude/settings.json at the working root. Written by whoever wrote the
44+ // repository, so what it grants waits for trust.
45+ ScopeProject Scope = "project"
46+ // ScopeLocal is the user's own, for this project only:
47+ // .claude/settings.local.json, which Claude Code keeps out of git, and the
48+ // per-project servers in ~/.claude.json, which `claude mcp add` writes.
49+ ScopeLocal Scope = "local"
50+ )
51+
52+ // File names, relative to the home directory or the working root.
53+ const (
54+ ClaudeJSON = ".claude.json"
55+ UserSettings = ".claude/settings.json"
56+ ProjectSettings = ".claude/settings.json"
57+ LocalSettings = ".claude/settings.local.json"
58+ ProjectMCP = ".mcp.json"
59+ UserInstructions = ".claude/CLAUDE.md"
60+ instructionsName = "CLAUDE.md"
61+ localInstructions = "CLAUDE.local.md"
62+ )
63+
64+ // readJSON decodes a file into v. A missing file is (false, nil): every file
65+ // here is optional, and most machines have none of them.
66+ func readJSON(path string, v any) (bool, error) {
67+ raw, err := os.ReadFile(path)
68+ if errors.Is(err, fs.ErrNotExist) {
69+ return false, nil
70+ }
71+ if err != nil {
72+ return false, err
73+ }
74+ if err := json.Unmarshal(raw, v); err != nil {
75+ return false, fmt.Errorf("%s is not valid JSON: %w", path, err)
76+ }
77+ return true, nil
78+ }
79+
80+ // shown is how a path is written in a message: under ~ when it is in the home
81+ // directory, relative when it is in the working root, absolute otherwise. A
82+ // prompt that says ".mcp.json" is one a person reads; one that says
83+ // /home/someone/src/thing/.mcp.json is one they skim.
84+ func shown(home, root, path string) string {
85+ if root != "" {
86+ if rel, err := filepath.Rel(root, path); err == nil && !strings.HasPrefix(rel, "..") {
87+ return filepath.ToSlash(rel)
88+ }
89+ }
90+ if home != "" {
91+ if rel, err := filepath.Rel(home, path); err == nil && !strings.HasPrefix(rel, "..") {
92+ return "~/" + filepath.ToSlash(rel)
93+ }
94+ }
95+ return path
96+ }
+293−0internal/ccsettings/load_test.goaddedView File
@@ -0,0 +1,293 @@
1+ package ccsettings
2+
3+ import (
4+ "os"
5+ "os/exec"
6+ "path/filepath"
7+ "reflect"
8+ "strings"
9+ "testing"
10+
11+ "github.com/johannSo/codey/internal/config"
12+ )
13+
14+ // write puts a file under dir, creating its parents. Every test here builds
15+ // its own home and root under t.TempDir: nothing may read the real ~/.claude*.
16+ func write(t *testing.T, dir, rel, body string) string {
17+ t.Helper()
18+ path := filepath.Join(dir, rel)
19+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+ return path
26+ }
27+
28+ func dirs(t *testing.T) (home, root string) {
29+ t.Helper()
30+ base := t.TempDir()
31+ home = filepath.Join(base, "home")
32+ root = filepath.Join(home, "src", "proj")
33+ if err := os.MkdirAll(root, 0o755); err != nil {
34+ t.Fatal(err)
35+ }
36+ return home, root
37+ }
38+
39+ func TestLoadPermissionsReadsTheThreeFiles(t *testing.T) {
40+ home, root := dirs(t)
41+ write(t, home, UserSettings, `{"permissions":{"allow":["Bash(go test:*)"],"defaultMode":"acceptEdits"},"model":"opus","env":{"X":"1"}}`)
42+ write(t, root, ProjectSettings, `{"permissions":{"allow":["Bash(make:*)"],"deny":["Read(.env)"]}}`)
43+ write(t, root, LocalSettings, `{"permissions":{"ask":["Bash(git push:*)"]}}`)
44+
45+ got, errs := LoadPermissions(home, root)
46+ if len(errs) != 0 {
47+ t.Fatalf("errors: %v", errs)
48+ }
49+ want := []Permissions{
50+ {Scope: ScopeUser, Shown: "~/.claude/settings.json", Allow: []string{"Bash(go test:*)"}, DefaultMode: "acceptEdits"},
51+ {Scope: ScopeProject, Shown: ".claude/settings.json", Allow: []string{"Bash(make:*)"}, Deny: []string{"Read(.env)"}},
52+ {Scope: ScopeLocal, Shown: ".claude/settings.local.json", Ask: []string{"Bash(git push:*)"}},
53+ }
54+ for i := range got {
55+ got[i].Path = ""
56+ }
57+ if !reflect.DeepEqual(got, want) {
58+ t.Errorf("got %+v\nwant %+v", got, want)
59+ }
60+ }
61+
62+ func TestLoadPermissionsShapes(t *testing.T) {
63+ tests := []struct {
64+ name string
65+ body string
66+ wantFiles int
67+ wantErr bool
68+ }{
69+ {"no file", "", 0, false},
70+ {"empty object", `{}`, 1, false},
71+ {"no permissions block", `{"hooks":{"PreToolUse":[]}}`, 1, false},
72+ {"broken JSON", `{"permissions":`, 0, true},
73+ }
74+ for _, tc := range tests {
75+ t.Run(tc.name, func(t *testing.T) {
76+ home, root := dirs(t)
77+ if tc.body != "" {
78+ write(t, root, ProjectSettings, tc.body)
79+ }
80+ got, errs := LoadPermissions(home, root)
81+ if len(got) != tc.wantFiles || (len(errs) > 0) != tc.wantErr {
82+ t.Errorf("files %d errs %v, want %d files, error %v", len(got), errs, tc.wantFiles, tc.wantErr)
83+ }
84+ })
85+ }
86+ }
87+
88+ // Started in the home directory, the user's settings are the user's, not a
89+ // project's: reading them a second time as shared project settings would hold
90+ // their allow rules hostage to a trust prompt about themselves.
91+ func TestLoadPermissionsRootIsHome(t *testing.T) {
92+ home, _ := dirs(t)
93+ write(t, home, UserSettings, `{"permissions":{"allow":["Edit"]}}`)
94+ got, _ := LoadPermissions(home, home)
95+ if len(got) != 1 || got[0].Scope != ScopeUser {
96+ t.Errorf("got %+v, want only the user file", got)
97+ }
98+ }
99+
100+ // A settings.local.json is supposed to be kept out of git. One that git is
101+ // tracking arrived with the clone, and gets no more trust than the shared
102+ // file.
103+ func TestTrackedLocalSettingsAreProjectScope(t *testing.T) {
104+ if _, err := exec.LookPath("git"); err != nil {
105+ t.Skip("no git")
106+ }
107+ home, root := dirs(t)
108+ write(t, root, LocalSettings, `{"permissions":{"allow":["Bash"]}}`)
109+ for _, args := range [][]string{{"init", "-q"}, {"add", "-f", LocalSettings}} {
110+ cmd := exec.Command("git", args...)
111+ cmd.Dir = root
112+ cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
113+ if out, err := cmd.CombinedOutput(); err != nil {
114+ t.Fatalf("git %v: %v\n%s", args, err, out)
115+ }
116+ }
117+ got, _ := LoadPermissions(home, root)
118+ if len(got) != 1 || got[0].Scope != ScopeProject || !got[0].Tracked {
119+ t.Fatalf("got %+v, want a tracked, project-scope file", got)
120+ }
121+ rs, _ := NewRuleset(home, root, got, false)
122+ if v := rs.Check("bash", []byte(`{"command":"ls"}`)); v.Verdict != 0 {
123+ t.Errorf("an untrusted, committed local file's allow rule was honoured: %+v", v)
124+ }
125+ }
126+
127+ func TestLoadMCPShapes(t *testing.T) {
128+ home, root := dirs(t)
129+ write(t, root, ProjectMCP, `{"mcpServers":{
130+ "plain": {"command":"plain-mcp","args":["--x"]},
131+ "typed": {"type":"stdio","command":"typed-mcp","env":{"K":"v"}},
132+ "web": {"type":"http","url":"https://example.com/mcp"},
133+ "old": {"type":"sse","url":"https://example.com/sse"},
134+ "bare": {"url":"https://example.com/u"}
135+ }}`)
136+ write(t, home, ClaudeJSON, `{
137+ "numStartups": 12,
138+ "mcpServers": {"mine": {"command":"mine-mcp"}},
139+ "projects": {
140+ "`+root+`": {"mcpServers": {"here": {"command":"here-mcp"}}, "allowedTools": []},
141+ "/elsewhere": {"mcpServers": {"there": {"command":"there-mcp"}}}
142+ }
143+ }`)
144+
145+ files, errs := LoadMCP(home, root)
146+ if len(errs) != 3 {
147+ t.Errorf("want one error per remote server, got %v", errs)
148+ }
149+ for _, err := range errs {
150+ if !strings.Contains(err.Error(), "stdio") {
151+ t.Errorf("error %q should say Codey speaks only stdio", err)
152+ }
153+ }
154+ var got []string
155+ for _, f := range files {
156+ got = append(got, string(f.Scope)+":"+strings.Join(sortedNames(f.Servers), ","))
157+ }
158+ want := []string{"local:here", "project:plain,typed", "user:mine"}
159+ if !reflect.DeepEqual(got, want) {
160+ t.Errorf("files %v, want %v (most specific first)", got, want)
161+ }
162+ }
163+
164+ // Claude Code keys a project by the path it was started in; a root reached
165+ // through a symlink is still that project.
166+ func TestLoadMCPFindsProjectThroughSymlink(t *testing.T) {
167+ home, root := dirs(t)
168+ link := filepath.Join(filepath.Dir(home), "link")
169+ if err := os.Symlink(root, link); err != nil {
170+ t.Skip(err)
171+ }
172+ write(t, home, ClaudeJSON, `{"projects":{"`+root+`":{"mcpServers":{"here":{"command":"x"}}}}}`)
173+ files, _ := LoadMCP(home, link)
174+ if len(files) != 1 || files[0].Scope != ScopeLocal {
175+ t.Errorf("got %+v", files)
176+ }
177+ }
178+
179+ func TestLoadMCPBrokenFileIsReported(t *testing.T) {
180+ home, root := dirs(t)
181+ write(t, root, ProjectMCP, `{"mcpServers": [`)
182+ files, errs := LoadMCP(home, root)
183+ if len(files) != 0 || len(errs) != 1 {
184+ t.Errorf("files %v errs %v", files, errs)
185+ }
186+ }
187+
188+ func env(m map[string]string) func(string) (string, bool) {
189+ return func(k string) (string, bool) { v, ok := m[k]; return v, ok }
190+ }
191+
192+ func TestMergeMCPPrecedence(t *testing.T) {
193+ files := []MCPFile{
194+ {Scope: ScopeLocal, Shown: "~/.claude.json (this project)", Servers: map[string]ServerDef{
195+ "db": {Command: "local-db"}, "only-local": {Command: "l"}}},
196+ {Scope: ScopeProject, Shown: ".mcp.json", Servers: map[string]ServerDef{
197+ "db": {Command: "project-db"}, "fs": {Command: "project-fs"}, "lint": {Command: "project-lint"}}},
198+ {Scope: ScopeUser, Shown: "~/.claude.json", Servers: map[string]ServerDef{
199+ "fs": {Command: "user-fs"}, "lint": {Command: "user-lint"}, "only-user": {Command: "u"}}},
200+ }
201+ own := map[string]config.MCPServer{"lint": {Command: "codey-lint"}}
202+
203+ tests := []struct {
204+ name string
205+ trusted bool
206+ want map[string]string
207+ wantNotes int
208+ }{
209+ {
210+ name: "trusted",
211+ trusted: true,
212+ want: map[string]string{
213+ "lint": "codey-lint", "db": "local-db", "fs": "project-fs",
214+ "only-local": "l", "only-user": "u",
215+ },
216+ // db: local over project; lint: codey over project and user;
217+ // fs: project over user.
218+ wantNotes: 4,
219+ },
220+ {
221+ name: "untrusted project servers give way to the user's own",
222+ trusted: false,
223+ want: map[string]string{
224+ "lint": "codey-lint", "db": "local-db", "fs": "user-fs",
225+ "only-local": "l", "only-user": "u",
226+ },
227+ wantNotes: 1,
228+ },
229+ }
230+ for _, tc := range tests {
231+ t.Run(tc.name, func(t *testing.T) {
232+ got, notes := MergeMCP(own, files, tc.trusted, env(nil))
233+ commands := map[string]string{}
234+ for n, s := range got {
235+ commands[n] = s.Command
236+ }
237+ if !reflect.DeepEqual(commands, tc.want) {
238+ t.Errorf("got %v, want %v", commands, tc.want)
239+ }
240+ if len(notes) != tc.wantNotes {
241+ t.Errorf("notes %q, want %d", notes, tc.wantNotes)
242+ }
243+ for _, n := range notes {
244+ if !strings.Contains(n, "using the one in") {
245+ t.Errorf("clash note %q should name the winner", n)
246+ }
247+ }
248+ })
249+ }
250+ }
251+
252+ func TestExpand(t *testing.T) {
253+ vars := env(map[string]string{"HOME": "/h", "EMPTY": "", "PORT": "5432"})
254+ tests := []struct {
255+ in, want string
256+ wantErr bool
257+ }{
258+ {"plain", "plain", false},
259+ {"${HOME}/bin/tool", "/h/bin/tool", false},
260+ {"--port=${PORT}", "--port=5432", false},
261+ {"${MISSING:-fallback}", "fallback", false},
262+ {"${EMPTY:-fallback}", "fallback", false},
263+ {"${HOME:-unused}", "/h", false},
264+ {"${MISSING:-}", "", false},
265+ {"${A:-x}${B:-y}", "xy", false},
266+ {"$HOME stays", "$HOME stays", false},
267+ {"${MISSING}", "", true},
268+ }
269+ for _, tc := range tests {
270+ got, err := expand(tc.in, vars)
271+ if (err != nil) != tc.wantErr || got != tc.want {
272+ t.Errorf("expand(%q) = %q, %v; want %q, error %v", tc.in, got, err, tc.want, tc.wantErr)
273+ }
274+ }
275+ }
276+
277+ func TestMergeMCPExpandsEverywhere(t *testing.T) {
278+ files := []MCPFile{{Scope: ScopeUser, Shown: "~/.claude.json", Servers: map[string]ServerDef{
279+ "db": {Command: "${BIN:-/usr/bin}/db", Args: []string{"--url", "${DB_URL}"}, Env: map[string]string{"TOKEN": "${TOKEN}"}},
280+ "broken": {Command: "x", Args: []string{"${NOPE}"}},
281+ }}}
282+ got, notes := MergeMCP(nil, files, false, env(map[string]string{"DB_URL": "pg://x", "TOKEN": "t"}))
283+ want := config.MCPServer{Command: "/usr/bin/db", Args: []string{"--url", "pg://x"}, Env: map[string]string{"TOKEN": "t"}}
284+ if !reflect.DeepEqual(got["db"], want) {
285+ t.Errorf("db = %+v, want %+v", got["db"], want)
286+ }
287+ if _, ok := got["broken"]; ok {
288+ t.Error("a server with an unset variable and no default should not start")
289+ }
290+ if len(notes) != 1 || !strings.Contains(notes[0], "NOPE") {
291+ t.Errorf("notes %q should name the missing variable", notes)
292+ }
293+ }
+376−0internal/ccsettings/match.goaddedView File
@@ -0,0 +1,376 @@
1+ package ccsettings
2+
3+ // TEMPORARY MATCHER — swap at merge.
4+ //
5+ // This file is a deliberately small parser and matcher for Claude Code
6+ // permission rules, written so the loading and honouring in this package could
7+ // be tested end to end while the shared rule parser (ParseRule / Rule.Matches)
8+ // was being written elsewhere. Everything outside this file talks to it
9+ // through compile and the compiled interface, so replacing it means writing an
10+ // adapter from the shared Rule to compiled and deleting this file. The
11+ // tri-state result and the allow-versus-deny distinction for compound shell
12+ // commands are the parts an adapter must keep; see Match and matchMode.
13+
14+ import (
15+ "encoding/json"
16+ "fmt"
17+ "net/url"
18+ "path/filepath"
19+ "regexp"
20+ "strings"
21+
22+ "github.com/bmatcuk/doublestar/v4"
23+
24+ "github.com/johannSo/codey/internal/approval"
25+ )
26+
27+ // Match is the result of checking one rule against one call.
28+ type Match int
29+
30+ const (
31+ NoMatch Match = iota
32+ Matched
33+ // Unsure means the call cannot be pinned down well enough to say — an
34+ // opaque shell command, a word built at run time. An allow rule treats it
35+ // as no match; a deny or ask rule treats it as a reason to ask.
36+ Unsure
37+ )
38+
39+ // matchMode is which kind of rule is asking. It matters for compound shell
40+ // commands: an allow rule covers "a && b" only if it covers both, while a
41+ // deny rule catches it if it catches either.
42+ type matchMode int
43+
44+ const (
45+ forAllow matchMode = iota
46+ forRestrict
47+ )
48+
49+ // compiled is one parsed rule.
50+ type compiled interface {
51+ Match(tool string, input json.RawMessage, mode matchMode) Match
52+ }
53+
54+ // matchEnv is what relative rule paths are resolved against.
55+ type matchEnv struct {
56+ home, root string
57+ }
58+
59+ // codeyTools maps Claude Code's tool names to Codey's. Edit covers both of
60+ // Codey's writing tools, as Claude Code's Edit rules cover all of its own; Read
61+ // covers the search tools as well, on a best-effort basis, as it does there.
62+ var codeyTools = map[string][]string{
63+ "Bash": {"bash"},
64+ "Read": {"read_file", "glob", "grep"},
65+ "Edit": {"edit_file", "write_file"},
66+ "MultiEdit": {"edit_file"},
67+ "Write": {"write_file"},
68+ "Glob": {"glob"},
69+ "Grep": {"grep"},
70+ "WebFetch": {"web_fetch"},
71+ "WebSearch": {"web_search"},
72+ "TodoWrite": {"todo_write"},
73+ }
74+
75+ var ruleShape = regexp.MustCompile(`^([A-Za-z0-9_\-]+(?:__\*)?)(?:\((.*)\))?$`)
76+
77+ // compile parses a rule. An error means the rule names something Codey has no
78+ // equivalent for, or uses a specifier this matcher does not understand; the
79+ // caller reports it and the rule takes no part.
80+ func compile(rule string, env matchEnv) (compiled, error) {
81+ m := ruleShape.FindStringSubmatch(strings.TrimSpace(rule))
82+ if m == nil {
83+ return nil, fmt.Errorf("not a rule Codey can read")
84+ }
85+ name, spec := m[1], strings.TrimSpace(m[2])
86+ hasSpec := spec != "" && spec != "*"
87+
88+ if strings.HasPrefix(name, "mcp__") {
89+ if m[2] != "" {
90+ return nil, fmt.Errorf("an MCP rule takes no specifier")
91+ }
92+ return mcpRule(name), nil
93+ }
94+ tools, ok := codeyTools[name]
95+ if !ok {
96+ return nil, fmt.Errorf("Codey has no %s tool", name)
97+ }
98+ if !hasSpec {
99+ return toolRule(tools), nil
100+ }
101+ switch name {
102+ case "Bash":
103+ return newBashRule(spec), nil
104+ case "Read", "Edit", "MultiEdit", "Write", "Glob", "Grep":
105+ return newPathRule(tools, spec, env), nil
106+ case "WebFetch":
107+ if d, ok := strings.CutPrefix(spec, "domain:"); ok && d != "" {
108+ return domainRule(strings.ToLower(d)), nil
109+ }
110+ }
111+ return nil, fmt.Errorf("Codey does not understand the specifier %q", spec)
112+ }
113+
114+ // toolRule is a bare tool name: every call of it.
115+ type toolRule []string
116+
117+ func (r toolRule) Match(tool string, _ json.RawMessage, _ matchMode) Match {
118+ for _, t := range r {
119+ if t == tool {
120+ return Matched
121+ }
122+ }
123+ return NoMatch
124+ }
125+
126+ // mcpRule is mcp__server, mcp__server__* or mcp__server__tool.
127+ type mcpRule string
128+
129+ func (r mcpRule) Match(tool string, _ json.RawMessage, _ matchMode) Match {
130+ name := string(r)
131+ if server, ok := strings.CutSuffix(name, "__*"); ok {
132+ name = server
133+ }
134+ if strings.Count(name, "__") == 1 {
135+ // The whole server.
136+ if strings.HasPrefix(tool, name+"__") {
137+ return Matched
138+ }
139+ return NoMatch
140+ }
141+ if tool == name {
142+ return Matched
143+ }
144+ return NoMatch
145+ }
146+
147+ // bashRule is Bash(spec): an exact command, a prefix written "cmd:*" or
148+ // "cmd *", or a pattern with * in it.
149+ type bashRule struct {
150+ prefix []string // set for a prefix rule
151+ exact []string // set for an exact rule
152+ pattern *regexp.Regexp
153+ }
154+
155+ func newBashRule(spec string) bashRule {
156+ if p, ok := strings.CutSuffix(spec, ":*"); ok {
157+ return bashRule{prefix: strings.Fields(p)}
158+ }
159+ if p, ok := strings.CutSuffix(spec, " *"); ok && !strings.Contains(p, "*") {
160+ return bashRule{prefix: strings.Fields(p)}
161+ }
162+ if strings.Contains(spec, "*") {
163+ parts := strings.Split(strings.Join(strings.Fields(spec), " "), "*")
164+ for i, p := range parts {
165+ parts[i] = regexp.QuoteMeta(p)
166+ }
167+ return bashRule{pattern: regexp.MustCompile("^" + strings.Join(parts, ".*") + "$")}
168+ }
169+ return bashRule{exact: strings.Fields(spec)}
170+ }
171+
172+ func (r bashRule) Match(tool string, input json.RawMessage, mode matchMode) Match {
173+ if tool != "bash" {
174+ return NoMatch
175+ }
176+ var args struct {
177+ Command string `json:"command"`
178+ }
179+ json.Unmarshal(input, &args)
180+ cmds, info := approval.SimpleCommands(args.Command)
181+
182+ if mode == forAllow {
183+ // Allowing is all or nothing: every command in the line must be
184+ // covered, and a line whose commands cannot all be seen covers
185+ // nothing.
186+ if info.Opaque || info.ParseError != nil || len(cmds) == 0 {
187+ return NoMatch
188+ }
189+ for _, c := range cmds {
190+ if !c.Pinned || r.one(c) != Matched {
191+ return NoMatch
192+ }
193+ }
194+ return Matched
195+ }
196+
197+ // Restricting needs one command to be caught. A line that cannot be seen
198+ // all the way through might hide one, which is Unsure rather than a pass.
199+ unsure := info.Opaque || info.ParseError != nil
200+ for _, c := range cmds {
201+ switch r.one(c) {
202+ case Matched:
203+ return Matched
204+ case Unsure:
205+ unsure = true
206+ }
207+ }
208+ if unsure {
209+ return Unsure
210+ }
211+ return NoMatch
212+ }
213+
214+ // one matches a single simple command.
215+ func (r bashRule) one(c approval.SimpleCommand) Match {
216+ switch {
217+ case r.prefix != nil:
218+ return wordsMatch(r.prefix, c, true)
219+ case r.exact != nil:
220+ return wordsMatch(r.exact, c, false)
221+ default:
222+ for _, lit := range c.Literal {
223+ if !lit {
224+ return Unsure
225+ }
226+ }
227+ if r.pattern.MatchString(strings.Join(c.Words, " ")) {
228+ return Matched
229+ }
230+ return NoMatch
231+ }
232+ }
233+
234+ // wordsMatch compares a rule's words to a command's. A word the shell builds
235+ // at run time could be anything, so meeting one before the comparison is
236+ // settled is Unsure.
237+ func wordsMatch(want []string, c approval.SimpleCommand, prefix bool) Match {
238+ if len(c.Words) < len(want) || (!prefix && len(c.Words) != len(want)) {
239+ return NoMatch
240+ }
241+ for i, w := range want {
242+ if !c.Literal[i] {
243+ return Unsure
244+ }
245+ if c.Words[i] != w {
246+ return NoMatch
247+ }
248+ }
249+ return Matched
250+ }
251+
252+ // pathRule is Read(spec), Edit(spec) and the like: a gitignore-style pattern
253+ // over the path a tool is handed.
254+ type pathRule struct {
255+ tools []string
256+ pattern string // absolute, slash-separated, doublestar syntax
257+ root string
258+ }
259+
260+ // newPathRule resolves a pattern the way Claude Code documents it:
261+ // //path is absolute, ~/path is under the home directory, /path is under
262+ // the project root, and anything else is relative to the working directory —
263+ // which for Codey is the root. A pattern with no slash in it matches at any
264+ // depth, as it would in .gitignore.
265+ func newPathRule(tools []string, spec string, env matchEnv) pathRule {
266+ spec = strings.TrimSuffix(spec, "/")
267+ var p string
268+ switch {
269+ case strings.HasPrefix(spec, "//"):
270+ p = spec[1:]
271+ case strings.HasPrefix(spec, "~/"):
272+ p = escape(filepath.ToSlash(env.home)) + spec[1:]
273+ case strings.HasPrefix(spec, "/"):
274+ p = escape(filepath.ToSlash(env.root)) + spec
275+ default:
276+ rel := strings.TrimPrefix(spec, "./")
277+ if !strings.Contains(rel, "/") && !strings.HasPrefix(spec, "./") {
278+ rel = "**/" + rel
279+ }
280+ p = escape(filepath.ToSlash(env.root)) + "/" + rel
281+ }
282+ return pathRule{tools: tools, pattern: p, root: env.root}
283+ }
284+
285+ func (r pathRule) Match(tool string, input json.RawMessage, mode matchMode) Match {
286+ if toolRule(r.tools).Match(tool, nil, mode) != Matched {
287+ return NoMatch
288+ }
289+ var args struct {
290+ Path string `json:"path"`
291+ }
292+ json.Unmarshal(input, &args)
293+ path := args.Path
294+ if path == "" {
295+ path = r.root
296+ }
297+ if !filepath.IsAbs(path) {
298+ path = filepath.Join(r.root, path)
299+ }
300+ candidates := []string{filepath.Clean(path)}
301+ // The tools follow symlinks, so a rule about .env has to catch a link
302+ // to it under another name.
303+ if real, err := filepath.EvalSymlinks(path); err == nil && real != candidates[0] {
304+ candidates = append(candidates, real)
305+ }
306+ hits := 0
307+ for _, c := range candidates {
308+ if r.covers(filepath.ToSlash(c)) {
309+ hits++
310+ }
311+ }
312+ switch {
313+ case mode == forAllow && hits == len(candidates):
314+ return Matched
315+ case mode == forRestrict && hits > 0:
316+ return Matched
317+ }
318+ return NoMatch
319+ }
320+
321+ // covers reports whether the pattern matches the path or any directory above
322+ // it: a rule about a directory, as in .gitignore, is a rule about everything
323+ // inside it.
324+ func (r pathRule) covers(path string) bool {
325+ for p := path; ; {
326+ if ok, _ := doublestar.Match(r.pattern, p); ok {
327+ return true
328+ }
329+ parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(p)))
330+ if parent == p {
331+ return false
332+ }
333+ p = parent
334+ }
335+ }
336+
337+ func escape(s string) string {
338+ var b strings.Builder
339+ for _, r := range s {
340+ if strings.ContainsRune(`*?[]{}\`, r) {
341+ b.WriteByte('\\')
342+ }
343+ b.WriteRune(r)
344+ }
345+ return b.String()
346+ }
347+
348+ // domainRule is WebFetch(domain:example.com). *.example.com covers the
349+ // subdomains; the bare name covers only itself.
350+ type domainRule string
351+
352+ func (r domainRule) Match(tool string, input json.RawMessage, _ matchMode) Match {
353+ if tool != "web_fetch" {
354+ return NoMatch
355+ }
356+ var args struct {
357+ URL string `json:"url"`
358+ }
359+ json.Unmarshal(input, &args)
360+ u, err := url.Parse(args.URL)
361+ if err != nil || u.Hostname() == "" {
362+ return Unsure
363+ }
364+ host := strings.ToLower(u.Hostname())
365+ d := string(r)
366+ if sub, ok := strings.CutPrefix(d, "*."); ok {
367+ if strings.HasSuffix(host, "."+sub) {
368+ return Matched
369+ }
370+ return NoMatch
371+ }
372+ if host == d {
373+ return Matched
374+ }
375+ return NoMatch
376+ }
+249−0internal/ccsettings/mcp.goaddedView File
@@ -0,0 +1,249 @@
1+ package ccsettings
2+
3+ import (
4+ "fmt"
5+ "path/filepath"
6+ "regexp"
7+ "sort"
8+ "strings"
9+
10+ "github.com/johannSo/codey/internal/config"
11+ )
12+
13+ // ServerDef is one MCP server as Claude Code writes it, before any ${VAR} in
14+ // it is expanded. It is kept unexpanded because that is the form the trust
15+ // decision is about: what the repository wrote, not what this shell happened
16+ // to substitute into it.
17+ type ServerDef struct {
18+ // Type is "stdio", or empty, which means the same thing. "http", "sse"
19+ // and "ws" are remote transports Codey's client does not speak.
20+ Type string `json:"type,omitempty"`
21+ Command string `json:"command,omitempty"`
22+ Args []string `json:"args,omitempty"`
23+ Env map[string]string `json:"env,omitempty"`
24+ URL string `json:"url,omitempty"`
25+ }
26+
27+ // stdio reports whether Codey can start this server. A url is a remote
28+ // server whatever the type says.
29+ func (d ServerDef) stdio() bool {
30+ return (d.Type == "" || d.Type == "stdio") && d.URL == ""
31+ }
32+
33+ // MCPFile is the servers one Claude Code file defines, the remote ones
34+ // already removed.
35+ type MCPFile struct {
36+ Scope Scope
37+ // Shown names the file in messages; for ~/.claude.json's per-project
38+ // servers it says which project.
39+ Shown string
40+ Servers map[string]ServerDef
41+ }
42+
43+ // mcpJSON is the shape of .mcp.json, and of the parts of ~/.claude.json read
44+ // here. ~/.claude.json carries a great deal more — login state, tips shown,
45+ // per-project history — none of which is any of Codey's business.
46+ type mcpJSON struct {
47+ MCPServers map[string]ServerDef `json:"mcpServers"`
48+ Projects map[string]struct {
49+ MCPServers map[string]ServerDef `json:"mcpServers"`
50+ } `json:"projects"`
51+ }
52+
53+ // LoadMCP reads the MCP servers Claude Code would offer in root, from the
54+ // three places it keeps them, most specific first:
55+ //
56+ // - ~/.claude.json, under projects[root] — "local" scope, the user's own
57+ // servers for this one project, which is what `claude mcp add` writes;
58+ // - .mcp.json at the root — "project" scope, committed to the repository;
59+ // - ~/.claude.json, top-level — "user" scope, for every project.
60+ //
61+ // That is Claude Code's own order of precedence, and the order MergeMCP
62+ // resolves name clashes in. Servers Codey cannot start — a url, or a type
63+ // other than stdio — are reported and left out here, so nothing downstream
64+ // has to know they existed.
65+ func LoadMCP(home, root string) ([]MCPFile, []error) {
66+ var errs []error
67+ var claude mcpJSON
68+ claudePath := ""
69+ if home != "" {
70+ claudePath = filepath.Join(home, ClaudeJSON)
71+ if _, err := readJSON(claudePath, &claude); err != nil {
72+ errs = append(errs, fmt.Errorf("claude code mcp: %w", err))
73+ }
74+ }
75+
76+ var files []MCPFile
77+ add := func(scope Scope, label string, defs map[string]ServerDef) {
78+ if len(defs) == 0 {
79+ return
80+ }
81+ kept := map[string]ServerDef{}
82+ for _, name := range sortedNames(defs) {
83+ d := defs[name]
84+ if !d.stdio() {
85+ transport := d.Type
86+ if transport == "" || transport == "stdio" {
87+ transport = "url"
88+ }
89+ errs = append(errs, fmt.Errorf("mcp: %s in %s is a remote server (%s), and Codey's client speaks only stdio — skipped", name, label, transport))
90+ continue
91+ }
92+ kept[name] = d
93+ }
94+ if len(kept) > 0 {
95+ files = append(files, MCPFile{Scope: scope, Shown: label, Servers: kept})
96+ }
97+ }
98+
99+ if claudePath != "" && root != "" {
100+ // Claude Code keys a project by the directory it was started in. A
101+ // root reached through a symlink is the same project under another
102+ // name, so both spellings are tried.
103+ for _, key := range rootKeys(root) {
104+ if p, ok := claude.Projects[key]; ok {
105+ add(ScopeLocal, "~/"+ClaudeJSON+" (this project)", p.MCPServers)
106+ break
107+ }
108+ }
109+ }
110+ if root != "" {
111+ var project mcpJSON
112+ path := filepath.Join(root, ProjectMCP)
113+ if _, err := readJSON(path, &project); err != nil {
114+ errs = append(errs, fmt.Errorf("claude code mcp: %w", err))
115+ } else {
116+ add(ScopeProject, ProjectMCP, project.MCPServers)
117+ }
118+ }
119+ if claudePath != "" {
120+ add(ScopeUser, "~/"+ClaudeJSON, claude.MCPServers)
121+ }
122+ return files, errs
123+ }
124+
125+ func rootKeys(root string) []string {
126+ keys := []string{filepath.Clean(root)}
127+ if real, err := filepath.EvalSymlinks(root); err == nil && real != keys[0] {
128+ keys = append(keys, real)
129+ }
130+ return keys
131+ }
132+
133+ // Project is the project-scope file in files, or nil.
134+ func Project(files []MCPFile) *MCPFile {
135+ for i := range files {
136+ if files[i].Scope == ScopeProject {
137+ return &files[i]
138+ }
139+ }
140+ return nil
141+ }
142+
143+ // MergeMCP combines Codey's own servers with Claude Code's into the set to
144+ // start, resolving each name to one definition and expanding ${VAR} in it.
145+ //
146+ // Codey's own config wins a clash, because it is the file written for this
147+ // program; after it, files are taken in the order LoadMCP returned them. Each
148+ // clash is reported once, naming the winner, because a server that silently
149+ // turned out to be a different program is the kind of surprise nobody thinks
150+ // to debug.
151+ //
152+ // trusted says whether the project's .mcp.json may be started. When it may
153+ // not, its servers are dropped before anything else is decided — so a user's
154+ // own server with the same name is the one that runs, rather than nothing.
155+ //
156+ // lookup is the environment ${VAR} is expanded from: os.LookupEnv in
157+ // production, a map in the tests.
158+ func MergeMCP(own map[string]config.MCPServer, files []MCPFile, trusted bool, lookup func(string) (string, bool)) (map[string]config.MCPServer, []string) {
159+ out := map[string]config.MCPServer{}
160+ from := map[string]string{}
161+ for name, s := range own {
162+ out[name] = s
163+ from[name] = "Codey's config"
164+ }
165+ var notes []string
166+ for _, f := range files {
167+ if f.Scope == ScopeProject && !trusted {
168+ continue
169+ }
170+ for _, name := range sortedNames(f.Servers) {
171+ if winner, taken := from[name]; taken {
172+ notes = append(notes, fmt.Sprintf("mcp: %s is defined in both %s and %s — using the one in %s", name, winner, f.Shown, winner))
173+ continue
174+ }
175+ spec, err := expandServer(f.Servers[name], lookup)
176+ if err != nil {
177+ notes = append(notes, fmt.Sprintf("mcp: %s in %s: %v — skipped", name, f.Shown, err))
178+ // Claimed all the same: a broken definition in a more specific
179+ // file should not quietly hand the name to a less specific one.
180+ from[name] = f.Shown
181+ continue
182+ }
183+ out[name] = spec
184+ from[name] = f.Shown
185+ }
186+ }
187+ return out, notes
188+ }
189+
190+ // envRef is ${VAR} or ${VAR:-default}, the two forms Claude Code expands in a
191+ // server definition.
192+ var envRef = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`)
193+
194+ // expand substitutes every ${VAR} in s. A variable that is unset, or set to
195+ // nothing, takes its default when it has one — the shell's reading of :- —
196+ // and is an error when it has none: Claude Code refuses such a definition
197+ // rather than starting a server with a hole in its command line, and so does
198+ // this.
199+ func expand(s string, lookup func(string) (string, bool)) (string, error) {
200+ var missing []string
201+ out := envRef.ReplaceAllStringFunc(s, func(ref string) string {
202+ m := envRef.FindStringSubmatch(ref)
203+ if v, ok := lookup(m[1]); ok && v != "" {
204+ return v
205+ }
206+ if m[2] != "" {
207+ return m[3]
208+ }
209+ missing = append(missing, m[1])
210+ return ""
211+ })
212+ if len(missing) > 0 {
213+ return "", fmt.Errorf("%s is not set and has no default", strings.Join(missing, ", "))
214+ }
215+ return out, nil
216+ }
217+
218+ func expandServer(d ServerDef, lookup func(string) (string, bool)) (config.MCPServer, error) {
219+ var err error
220+ s := config.MCPServer{}
221+ if s.Command, err = expand(d.Command, lookup); err != nil {
222+ return s, err
223+ }
224+ for _, a := range d.Args {
225+ v, err := expand(a, lookup)
226+ if err != nil {
227+ return s, err
228+ }
229+ s.Args = append(s.Args, v)
230+ }
231+ if len(d.Env) > 0 {
232+ s.Env = map[string]string{}
233+ for k, v := range d.Env {
234+ if s.Env[k], err = expand(v, lookup); err != nil {
235+ return s, err
236+ }
237+ }
238+ }
239+ return s, nil
240+ }
241+
242+ func sortedNames[V any](m map[string]V) []string {
243+ names := make([]string, 0, len(m))
244+ for n := range m {
245+ names = append(names, n)
246+ }
247+ sort.Strings(names)
248+ return names
249+ }
+106−0internal/ccsettings/permissions.goaddedView File
@@ -0,0 +1,106 @@
1+ package ccsettings
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "path/filepath"
8+ "time"
9+ )
10+
11+ // Permissions is the permissions block of one Claude Code settings file, as
12+ // written. Which of it is honoured is decided by NewRuleset, not here.
13+ type Permissions struct {
14+ Scope Scope
15+ // Path is the file, and Shown is how a message names it.
16+ Path string
17+ Shown string
18+ Allow []string
19+ Deny []string
20+ Ask []string
21+ // DefaultMode is Claude Code's starting permission mode. Only
22+ // "acceptEdits" and "default" mean anything to Codey, and only from a
23+ // file the user wrote.
24+ DefaultMode string
25+ // Tracked is set on a settings.local.json that git is tracking. Claude
26+ // Code keeps that file out of git, so one that is committed came from the
27+ // repository like the shared file did, and is treated as project scope.
28+ Tracked bool
29+ }
30+
31+ // settingsFile is the part of a settings file this package reads. Hooks, env,
32+ // model, statusLine and the rest are other harnesses' business; see the
33+ // README for what is deliberately not carried over.
34+ type settingsFile struct {
35+ Permissions struct {
36+ Allow []string `json:"allow"`
37+ Deny []string `json:"deny"`
38+ Ask []string `json:"ask"`
39+ DefaultMode string `json:"defaultMode"`
40+ } `json:"permissions"`
41+ }
42+
43+ // LoadPermissions reads the three settings files Claude Code would read for
44+ // root, in the order it would read them: the user's, the project's shared
45+ // file, and the project's local one. A file that is not there is skipped
46+ // silently; one that cannot be parsed is reported and skipped, which leaves
47+ // its deny rules unenforced — so the error is worth putting in front of the
48+ // user rather than in a log.
49+ //
50+ // home may be empty, for a machine with no home directory; the user's file is
51+ // then not read.
52+ func LoadPermissions(home, root string) ([]Permissions, []error) {
53+ type candidate struct {
54+ scope Scope
55+ path string
56+ }
57+ var files []candidate
58+ if home != "" {
59+ files = append(files, candidate{ScopeUser, filepath.Join(home, UserSettings)})
60+ }
61+ // A root that is the home directory would read the user's file twice,
62+ // once as a project file; it is the user's.
63+ if root != "" && filepath.Clean(root) != filepath.Clean(home) {
64+ files = append(files,
65+ candidate{ScopeProject, filepath.Join(root, ProjectSettings)},
66+ candidate{ScopeLocal, filepath.Join(root, LocalSettings)})
67+ }
68+
69+ var out []Permissions
70+ var errs []error
71+ for _, f := range files {
72+ var s settingsFile
73+ ok, err := readJSON(f.path, &s)
74+ if err != nil {
75+ errs = append(errs, fmt.Errorf("claude code settings: %w", err))
76+ continue
77+ }
78+ if !ok {
79+ continue
80+ }
81+ p := Permissions{
82+ Scope: f.scope,
83+ Path: f.path,
84+ Shown: shown(home, root, f.path),
85+ Allow: s.Permissions.Allow,
86+ Deny: s.Permissions.Deny,
87+ Ask: s.Permissions.Ask,
88+ DefaultMode: s.Permissions.DefaultMode,
89+ }
90+ if f.scope == ScopeLocal && gitTracks(root, LocalSettings) {
91+ p.Scope, p.Tracked = ScopeProject, true
92+ }
93+ out = append(out, p)
94+ }
95+ return out, errs
96+ }
97+
98+ // gitTracks reports whether git has rel in its index under root. Any failure
99+ // — no git, not a repository — reads as untracked, which is the honest answer
100+ // for a directory git knows nothing about: nobody committed the file.
101+ func gitTracks(root, rel string) bool {
102+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
103+ defer cancel()
104+ cmd := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "--error-unmatch", "--", rel)
105+ return cmd.Run() == nil
106+ }
+156−0internal/ccsettings/ruleset.goaddedView File
@@ -0,0 +1,156 @@
1+ package ccsettings
2+
3+ import (
4+ "encoding/json"
5+ "fmt"
6+
7+ "github.com/johannSo/codey/internal/approval"
8+ )
9+
10+ // Ruleset is Claude Code's permission rules as Codey honours them. It
11+ // implements approval.RuleSet, so the approval policy consults it before
12+ // anything else — before -yolo, before the read-only list, and before the
13+ // session's own memory.
14+ type Ruleset struct {
15+ deny, ask, allow []entry
16+ // acceptEdits names the file whose defaultMode said acceptEdits, empty
17+ // when none did.
18+ acceptEdits string
19+ lines []string
20+ }
21+
22+ type entry struct {
23+ rule, source string
24+ c compiled
25+ }
26+
27+ // NewRuleset decides which of the rules in perms are honoured and compiles
28+ // them. The rules, by kind and by who wrote them:
29+ //
30+ // - deny and ask, from every file. They can only make the agent ask more or
31+ // do less, and nobody is put at risk by that, so a repository may add as
32+ // many as it likes.
33+ // - allow, from the user's settings and the project's local file. Both are
34+ // the user's own writing.
35+ // - allow from the project's shared file — or from a settings.local.json
36+ // git is tracking, which is the same thing under another name — only when
37+ // projectTrusted. Otherwise a repository could decide for itself what
38+ // runs on your machine without asking, which is precisely the decision
39+ // the prompt exists to leave with you.
40+ // - defaultMode, only acceptEdits, and only from the user's own files. The
41+ // other modes either mean nothing here or mean "stop asking", and the
42+ // only way to stop asking in Codey is to say so on the command line.
43+ //
44+ // The notes are rules that were not understood and modes that were ignored,
45+ // for the caller to report; an untrusted project's allow rules are left for
46+ // the caller to report too, since only it knows how to ask for trust.
47+ func NewRuleset(home, root string, perms []Permissions, projectTrusted bool) (*Ruleset, []string) {
48+ env := matchEnv{home: home, root: root}
49+ r := &Ruleset{}
50+ var notes []string
51+ for _, p := range perms {
52+ label := p.Shown
53+ switch {
54+ case p.Tracked:
55+ label += " (committed to the repository, so treated as shared)"
56+ case p.Scope == ScopeProject:
57+ label += " (shared, from the repository)"
58+ }
59+ var lines []string
60+ add := func(kind string, rules []string, into *[]entry, honoured bool) {
61+ for _, rule := range rules {
62+ c, err := compile(rule, env)
63+ switch {
64+ case err != nil:
65+ notes = append(notes, fmt.Sprintf("claude code rule %s %s in %s ignored: %v", kind, rule, p.Shown, err))
66+ lines = append(lines, fmt.Sprintf(" %-6s %s — ignored: %v", kind, rule, err))
67+ case !honoured:
68+ lines = append(lines, fmt.Sprintf(" %-6s %s — ignored until this project is trusted", kind, rule))
69+ default:
70+ *into = append(*into, entry{rule: rule, source: p.Shown, c: c})
71+ lines = append(lines, fmt.Sprintf(" %-6s %s", kind, rule))
72+ }
73+ }
74+ }
75+ add("deny", p.Deny, &r.deny, true)
76+ add("ask", p.Ask, &r.ask, true)
77+ add("allow", p.Allow, &r.allow, p.Scope != ScopeProject || projectTrusted)
78+
79+ switch mode := p.DefaultMode; {
80+ case mode == "" || mode == "default":
81+ case mode == "acceptEdits" && p.Scope != ScopeProject:
82+ if r.acceptEdits == "" {
83+ r.acceptEdits = p.Shown
84+ }
85+ lines = append(lines, " mode acceptEdits — file edits run without asking")
86+ case p.Scope == ScopeProject:
87+ notes = append(notes, fmt.Sprintf("claude code defaultMode %q in %s ignored: a repository does not get to choose how often you are asked", mode, p.Shown))
88+ lines = append(lines, fmt.Sprintf(" mode %s — ignored: set by the repository", mode))
89+ default:
90+ notes = append(notes, fmt.Sprintf("claude code defaultMode %q in %s ignored: Codey honours only acceptEdits and default; -yolo is the way to stop asking", mode, p.Shown))
91+ lines = append(lines, fmt.Sprintf(" mode %s — ignored", mode))
92+ }
93+ if len(lines) > 0 {
94+ r.lines = append(r.lines, label)
95+ r.lines = append(r.lines, lines...)
96+ }
97+ }
98+ return r, notes
99+ }
100+
101+ // Empty reports whether there is nothing here to consult or to show, so a
102+ // caller can leave the policy's rule set nil.
103+ func (r *Ruleset) Empty() bool { return r == nil || len(r.lines) == 0 }
104+
105+ // Check implements approval.RuleSet. Deny outranks ask outranks allow, as in
106+ // Claude Code, so no allow rule anywhere can argue a call past a deny rule.
107+ func (r *Ruleset) Check(tool string, input json.RawMessage) approval.Ruling {
108+ if r == nil {
109+ return approval.Ruling{}
110+ }
111+ // A deny rule that cannot tell whether it applies — a shell command it
112+ // cannot see all the way through — does not get to refuse, since the
113+ // command may be innocent, but it does not get to wave it through either.
114+ // It becomes a question.
115+ var unsure *entry
116+ for i, e := range r.deny {
117+ switch e.c.Match(tool, input, forRestrict) {
118+ case Matched:
119+ return approval.Ruling{Verdict: approval.VerdictDeny, Rule: e.rule, Source: e.source}
120+ case Unsure:
121+ if unsure == nil {
122+ unsure = &r.deny[i]
123+ }
124+ }
125+ }
126+ for _, e := range r.ask {
127+ if e.c.Match(tool, input, forRestrict) != NoMatch {
128+ return approval.Ruling{Verdict: approval.VerdictAsk, Rule: e.rule, Source: e.source}
129+ }
130+ }
131+ if unsure != nil {
132+ return approval.Ruling{
133+ Verdict: approval.VerdictAsk,
134+ Rule: unsure.rule,
135+ Source: unsure.source + ", a deny rule this command cannot be checked against",
136+ }
137+ }
138+ for _, e := range r.allow {
139+ if e.c.Match(tool, input, forAllow) == Matched {
140+ return approval.Ruling{Verdict: approval.VerdictAllow, Rule: e.rule, Source: e.source}
141+ }
142+ }
143+ if r.acceptEdits != "" && (tool == "edit_file" || tool == "write_file") {
144+ return approval.Ruling{Verdict: approval.VerdictAllow, Rule: "defaultMode acceptEdits", Source: r.acceptEdits}
145+ }
146+ return approval.Ruling{}
147+ }
148+
149+ // Lines implements approval.RuleSet: every file, then its rules, each marked
150+ // with whether it is in force.
151+ func (r *Ruleset) Lines() []string {
152+ if r == nil {
153+ return nil
154+ }
155+ return r.lines
156+ }
+300−0internal/ccsettings/ruleset_test.goaddedView File
@@ -0,0 +1,300 @@
1+ package ccsettings
2+
3+ import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/johannSo/codey/internal/approval"
11+ )
12+
13+ func bash(cmd string) json.RawMessage {
14+ b, _ := json.Marshal(map[string]string{"command": cmd})
15+ return b
16+ }
17+
18+ func pathArg(p string) json.RawMessage {
19+ b, _ := json.Marshal(map[string]string{"path": p})
20+ return b
21+ }
22+
23+ func TestBashRules(t *testing.T) {
24+ env := matchEnv{home: "/home/u", root: "/src/proj"}
25+ tests := []struct {
26+ rule, cmd string
27+ allow Match // as an allow rule
28+ restrict Match // as a deny or ask rule
29+ }{
30+ // Prefixes, in both spellings, at a word boundary.
31+ {"Bash(npm run test:*)", "npm run test", Matched, Matched},
32+ {"Bash(npm run test:*)", "npm run test -- --watch", Matched, Matched},
33+ {"Bash(npm run test:*)", "npm run testing", NoMatch, NoMatch},
34+ {"Bash(git status *)", "git status --short", Matched, Matched},
35+ {"Bash(git status *)", "git status", Matched, Matched},
36+ // Exact.
37+ {"Bash(make)", "make", Matched, Matched},
38+ {"Bash(make)", "make install", NoMatch, NoMatch},
39+ // A pattern.
40+ {"Bash(git * main)", "git push origin main", Matched, Matched},
41+ {"Bash(git * main)", "git push origin dev", NoMatch, NoMatch},
42+ // Bare and star mean every command.
43+ {"Bash", "rm -rf /", Matched, Matched},
44+ {"Bash(*)", "anything at all", Matched, Matched},
45+ // Compound commands: an allow must cover every part, a restriction
46+ // any part.
47+ {"Bash(npm run test:*)", "npm run test && rm -rf ~", NoMatch, Matched},
48+ {"Bash(rm:*)", "npm run test && rm -rf ~", NoMatch, Matched},
49+ {"Bash(rm:*)", "ls; (cd x && rm y)", NoMatch, Matched},
50+ {"Bash(rm:*)", "for f in a b; do rm $f; done", Matched, Matched},
51+ {"Bash(git:*)", "git status | git log", Matched, Matched},
52+ {"Bash(git:*)", "git status | tee out", NoMatch, Matched},
53+ // Quotes are removed before comparing.
54+ {"Bash(git commit:*)", `git "commit" -m 'a b'`, Matched, Matched},
55+ // Opaque commands never match an allow rule, and are not ruled out
56+ // by a restriction.
57+ {"Bash(echo:*)", "echo $(curl -s evil.example)", NoMatch, Matched},
58+ {"Bash(curl:*)", "echo $(curl -s evil.example)", NoMatch, Matched},
59+ {"Bash(rm:*)", "echo $(curl -s evil.example)", NoMatch, Unsure},
60+ {"Bash(rm:*)", "echo `id`", NoMatch, Unsure},
61+ {"Bash(bash:*)", `bash -c "rm -rf ~"`, NoMatch, Matched},
62+ {"Bash(rm:*)", `bash -c "rm -rf ~"`, NoMatch, Unsure},
63+ {"Bash(cat:*)", "cat <(curl evil.example)", NoMatch, Matched},
64+ {"Bash(rm:*)", "cat <(curl evil.example)", NoMatch, Unsure},
65+ {"Bash(rm:*)", "$CMD -rf ~", NoMatch, Unsure},
66+ {"Bash(git push:*)", `git "$sub" origin`, NoMatch, Unsure},
67+ {"Bash(echo:*)", "echo 'unterminated", NoMatch, Unsure},
68+ // What a command does beyond its words: an assignment that can change
69+ // what the name means, a write the words do not mention.
70+ {"Bash(git:*)", "PATH=. git status", NoMatch, Matched},
71+ {"Bash(echo:*)", "echo hi > ~/.bashrc", NoMatch, Matched},
72+ {"Bash(go test:*)", "go test ./... 2>&1", Matched, Matched},
73+ {"Bash(go test:*)", "go test ./... > /dev/null", Matched, Matched},
74+ {"Bash(sort:*)", "sort < in.txt", Matched, Matched},
75+ // Other tools are not bash.
76+ {"Bash", "", NoMatch, NoMatch},
77+ }
78+ for _, tc := range tests {
79+ c, err := compile(tc.rule, env)
80+ if err != nil {
81+ t.Fatalf("%s: %v", tc.rule, err)
82+ }
83+ tool := "bash"
84+ if tc.cmd == "" {
85+ tool = "edit_file"
86+ }
87+ if got := c.Match(tool, bash(tc.cmd), forAllow); got != tc.allow {
88+ t.Errorf("allow %s on %q = %v, want %v", tc.rule, tc.cmd, got, tc.allow)
89+ }
90+ if got := c.Match(tool, bash(tc.cmd), forRestrict); got != tc.restrict {
91+ t.Errorf("restrict %s on %q = %v, want %v", tc.rule, tc.cmd, got, tc.restrict)
92+ }
93+ }
94+ }
95+
96+ func TestPathRules(t *testing.T) {
97+ base := t.TempDir()
98+ home := filepath.Join(base, "home")
99+ root := filepath.Join(home, "proj")
100+ env := matchEnv{home: home, root: root}
101+ tests := []struct {
102+ rule, tool, path string
103+ want Match
104+ }{
105+ // No slash: any depth, as in .gitignore.
106+ {"Read(.env)", "read_file", ".env", Matched},
107+ {"Read(.env)", "read_file", "deploy/.env", Matched},
108+ {"Read(.env)", "read_file", ".env.example", NoMatch},
109+ {"Read(*.pem)", "read_file", "certs/server.pem", Matched},
110+ // ./ anchors to the root.
111+ {"Read(./.env)", "read_file", "deploy/.env", NoMatch},
112+ {"Read(./.env)", "read_file", root + "/.env", Matched},
113+ // /path is under the project root; //path is absolute.
114+ {"Edit(/src/**/*.ts)", "edit_file", "src/a/b.ts", Matched},
115+ {"Edit(/src/**/*.ts)", "edit_file", "lib/b.ts", NoMatch},
116+ {"Read(//etc/passwd)", "read_file", "/etc/passwd", Matched},
117+ {"Read(~/.ssh/**)", "read_file", home + "/.ssh/id_ed25519", Matched},
118+ // A directory covers what is inside it.
119+ {"Read(secrets)", "read_file", "secrets/prod/key", Matched},
120+ {"Edit(docs/)", "write_file", "docs/new.md", Matched},
121+ // Edit covers both writing tools; Read covers the search tools.
122+ {"Edit(*.md)", "write_file", "README.md", Matched},
123+ {"Write(*.md)", "edit_file", "README.md", NoMatch},
124+ {"Read(secrets)", "grep", "secrets", Matched},
125+ {"Read(secrets)", "bash", "secrets", NoMatch},
126+ }
127+ for _, tc := range tests {
128+ c, err := compile(tc.rule, env)
129+ if err != nil {
130+ t.Fatalf("%s: %v", tc.rule, err)
131+ }
132+ if got := c.Match(tc.tool, pathArg(tc.path), forRestrict); got != tc.want {
133+ t.Errorf("%s on %s %s = %v, want %v", tc.rule, tc.tool, tc.path, got, tc.want)
134+ }
135+ }
136+ }
137+
138+ // The file tools follow symlinks, so a rule about a file catches a link to it,
139+ // and an allow rule does not cover a link that leads out of what it allows.
140+ func TestPathRulesFollowSymlinks(t *testing.T) {
141+ root := t.TempDir()
142+ env := matchEnv{home: t.TempDir(), root: root}
143+ os.WriteFile(filepath.Join(root, ".env"), []byte("SECRET=1"), 0o644)
144+ if err := os.Symlink(filepath.Join(root, ".env"), filepath.Join(root, "notes.md")); err != nil {
145+ t.Skip(err)
146+ }
147+ deny, _ := compile("Read(.env)", env)
148+ if got := deny.Match("read_file", pathArg("notes.md"), forRestrict); got != Matched {
149+ t.Errorf("a link to .env escaped Read(.env): %v", got)
150+ }
151+ allow, _ := compile("Edit(*.md)", env)
152+ if got := allow.Match("edit_file", pathArg("notes.md"), forAllow); got != NoMatch {
153+ t.Errorf("Edit(*.md) allowed editing .env through a link: %v", got)
154+ }
155+ }
156+
157+ func TestOtherRules(t *testing.T) {
158+ env := matchEnv{home: "/h", root: "/r"}
159+ fetch := func(u string) json.RawMessage { b, _ := json.Marshal(map[string]string{"url": u}); return b }
160+ tests := []struct {
161+ rule, tool string
162+ input json.RawMessage
163+ want Match
164+ }{
165+ {"WebFetch(domain:docs.rs)", "web_fetch", fetch("https://docs.rs/serde"), Matched},
166+ {"WebFetch(domain:docs.rs)", "web_fetch", fetch("https://evil.docs.rs.example/x"), NoMatch},
167+ {"WebFetch(domain:*.github.com)", "web_fetch", fetch("https://api.github.com/x"), Matched},
168+ {"WebFetch", "web_fetch", fetch("https://anything"), Matched},
169+ {"WebSearch", "web_search", nil, Matched},
170+ {"TodoWrite", "todo_write", nil, Matched},
171+ {"Glob", "glob", nil, Matched},
172+ {"MultiEdit", "edit_file", nil, Matched},
173+ {"mcp__db", "mcp__db__query", nil, Matched},
174+ {"mcp__db__*", "mcp__db__query", nil, Matched},
175+ {"mcp__db", "mcp__dbx__query", nil, NoMatch},
176+ {"mcp__db__query", "mcp__db__query", nil, Matched},
177+ {"mcp__db__query", "mcp__db__drop", nil, NoMatch},
178+ }
179+ for _, tc := range tests {
180+ c, err := compile(tc.rule, env)
181+ if err != nil {
182+ t.Fatalf("%s: %v", tc.rule, err)
183+ }
184+ if got := c.Match(tc.tool, tc.input, forAllow); got != tc.want {
185+ t.Errorf("%s on %s = %v, want %v", tc.rule, tc.tool, got, tc.want)
186+ }
187+ }
188+ for _, bad := range []string{"NotebookEdit", "Task(explore)", "WebFetch(https://x)", "mcp__db(x)", "Bash(", "WebSearch(cats)"} {
189+ if _, err := compile(bad, env); err == nil {
190+ t.Errorf("%s compiled; want an error so it is reported", bad)
191+ }
192+ }
193+ }
194+
195+ func perms(scope Scope, shown string, allow, deny, ask []string, mode string) Permissions {
196+ return Permissions{Scope: scope, Shown: shown, Allow: allow, Deny: deny, Ask: ask, DefaultMode: mode}
197+ }
198+
199+ func TestRulesetHonouring(t *testing.T) {
200+ user := perms(ScopeUser, "~/.claude/settings.json", []string{"Bash(git:*)", "Bash(rm:*)"}, nil, nil, "")
201+ project := perms(ScopeProject, ".claude/settings.json", []string{"Bash(make:*)"}, []string{"Bash(rm:*)"}, []string{"Bash(git push:*)"}, "")
202+ local := perms(ScopeLocal, ".claude/settings.local.json", []string{"Bash(go test:*)"}, nil, nil, "")
203+ all := []Permissions{user, project, local}
204+
205+ tests := []struct {
206+ name string
207+ trusted bool
208+ cmd string
209+ want approval.Verdict
210+ source string
211+ }{
212+ {"user allow", false, "git status", approval.VerdictAllow, "~/.claude/settings.json"},
213+ {"local allow", false, "go test ./...", approval.VerdictAllow, ".claude/settings.local.json"},
214+ {"untrusted project allow is ignored", false, "make build", approval.NoVerdict, ""},
215+ {"trusted project allow", true, "make build", approval.VerdictAllow, ".claude/settings.json"},
216+ {"a project's deny beats the user's allow", false, "rm -rf build", approval.VerdictDeny, ".claude/settings.json"},
217+ {"deny catches one part of a compound", true, "make && rm -rf build", approval.VerdictDeny, ".claude/settings.json"},
218+ {"ask beats allow", false, "git push origin main", approval.VerdictAsk, ".claude/settings.json"},
219+ {"an opaque command under a deny rule is asked about", false, "git $(echo status)", approval.VerdictAsk, ""},
220+ {"nothing said", false, "ls", approval.NoVerdict, ""},
221+ }
222+ for _, tc := range tests {
223+ t.Run(tc.name, func(t *testing.T) {
224+ rs, _ := NewRuleset("/h", "/r", all, tc.trusted)
225+ got := rs.Check("bash", bash(tc.cmd))
226+ if got.Verdict != tc.want {
227+ t.Fatalf("verdict %v (%+v), want %v", got.Verdict, got, tc.want)
228+ }
229+ if tc.source != "" && got.Source != tc.source {
230+ t.Errorf("source %q, want %q", got.Source, tc.source)
231+ }
232+ })
233+ }
234+ }
235+
236+ func TestRulesetDefaultMode(t *testing.T) {
237+ edit := pathArg("a.go")
238+ tests := []struct {
239+ name string
240+ p Permissions
241+ want approval.Verdict
242+ notes int
243+ }{
244+ {"acceptEdits from the user", perms(ScopeUser, "u", nil, nil, nil, "acceptEdits"), approval.VerdictAllow, 0},
245+ {"acceptEdits from local", perms(ScopeLocal, "l", nil, nil, nil, "acceptEdits"), approval.VerdictAllow, 0},
246+ {"acceptEdits from the repository", perms(ScopeProject, "p", nil, nil, nil, "acceptEdits"), approval.NoVerdict, 1},
247+ {"bypassPermissions from the repository", perms(ScopeProject, "p", nil, nil, nil, "bypassPermissions"), approval.NoVerdict, 1},
248+ {"bypassPermissions from the user", perms(ScopeUser, "u", nil, nil, nil, "bypassPermissions"), approval.NoVerdict, 1},
249+ {"default", perms(ScopeUser, "u", nil, nil, nil, "default"), approval.NoVerdict, 0},
250+ }
251+ for _, tc := range tests {
252+ t.Run(tc.name, func(t *testing.T) {
253+ rs, notes := NewRuleset("/h", "/r", []Permissions{tc.p}, true)
254+ if got := rs.Check("edit_file", edit).Verdict; got != tc.want {
255+ t.Errorf("edit_file verdict %v, want %v", got, tc.want)
256+ }
257+ if got := rs.Check("bash", bash("ls")).Verdict; got != approval.NoVerdict {
258+ t.Errorf("acceptEdits let bash through: %v", got)
259+ }
260+ if len(notes) != tc.notes {
261+ t.Errorf("notes %q, want %d", notes, tc.notes)
262+ }
263+ })
264+ }
265+ // A deny still beats the mode.
266+ rs, _ := NewRuleset("/h", "/r", []Permissions{perms(ScopeUser, "u", nil, []string{"Edit(*.go)"}, nil, "acceptEdits")}, false)
267+ if got := rs.Check("edit_file", edit).Verdict; got != approval.VerdictDeny {
268+ t.Errorf("deny under acceptEdits = %v", got)
269+ }
270+ }
271+
272+ func TestRulesetLines(t *testing.T) {
273+ all := []Permissions{
274+ perms(ScopeUser, "~/.claude/settings.json", []string{"Bash(go test:*)"}, nil, nil, "acceptEdits"),
275+ perms(ScopeProject, ".claude/settings.json", []string{"Bash(make:*)"}, []string{"Read(.env)"}, nil, ""),
276+ perms(ScopeLocal, ".claude/settings.local.json", []string{"NotebookEdit"}, nil, nil, ""),
277+ }
278+ rs, notes := NewRuleset("/h", "/r", all, false)
279+ text := strings.Join(rs.Lines(), "\n")
280+ for _, want := range []string{
281+ "~/.claude/settings.json",
282+ "allow Bash(go test:*)",
283+ "acceptEdits",
284+ ".claude/settings.json (shared, from the repository)",
285+ "deny Read(.env)",
286+ "allow Bash(make:*) — ignored until this project is trusted",
287+ "NotebookEdit — ignored: Codey has no NotebookEdit tool",
288+ } {
289+ if !strings.Contains(text, want) {
290+ t.Errorf("lines missing %q:\n%s", want, text)
291+ }
292+ }
293+ if len(notes) != 1 {
294+ t.Errorf("notes %q, want the one unreadable rule", notes)
295+ }
296+ empty, _ := NewRuleset("/h", "/r", nil, false)
297+ if !empty.Empty() {
298+ t.Error("no files should be empty")
299+ }
300+ }
+194−0internal/ccsettings/trust.goaddedView File
@@ -0,0 +1,194 @@
1+ package ccsettings
2+
3+ import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io/fs"
10+ "os"
11+ "path/filepath"
12+ "slices"
13+ "strings"
14+ "time"
15+ )
16+
17+ // Grant is everything a project's committed Claude Code files would grant if
18+ // trusted: the servers its .mcp.json would start, and the allow rules its
19+ // shared settings would honour. One question covers both, because both are
20+ // the same question — do you trust what this repository says about itself —
21+ // and two prompts at startup for one decision is one too many.
22+ type Grant struct {
23+ // Servers are the stdio servers .mcp.json defines, unexpanded.
24+ Servers map[string]ServerDef
25+ // Allow is the allow rules from every project-scope settings file, and
26+ // AllowFrom names those files.
27+ Allow []string
28+ AllowFrom []string
29+ }
30+
31+ // NewGrant collects what the project-scope files would grant.
32+ func NewGrant(mcp []MCPFile, perms []Permissions) Grant {
33+ var g Grant
34+ if p := Project(mcp); p != nil {
35+ g.Servers = p.Servers
36+ }
37+ for _, p := range perms {
38+ if p.Scope == ScopeProject && len(p.Allow) > 0 {
39+ g.Allow = append(g.Allow, p.Allow...)
40+ g.AllowFrom = append(g.AllowFrom, p.Shown)
41+ }
42+ }
43+ return g
44+ }
45+
46+ // Empty reports whether there is nothing to ask about.
47+ func (g Grant) Empty() bool { return len(g.Servers) == 0 && len(g.Allow) == 0 }
48+
49+ // Fingerprint identifies exactly these definitions. A trust decision is
50+ // recorded against it, so a .mcp.json that changes — a new server, a
51+ // different command for an old one, one more allow rule — is a new question
52+ // rather than one answered last month about something else.
53+ //
54+ // It covers the definitions as written, before ${VAR} is expanded: what the
55+ // repository says is what is being trusted, and the user's environment is the
56+ // user's own.
57+ func (g Grant) Fingerprint() string {
58+ allow := slices.Clone(g.Allow)
59+ slices.Sort(allow)
60+ // encoding/json writes map keys sorted, so equal definitions always
61+ // encode to equal bytes.
62+ body, _ := json.Marshal(struct {
63+ Servers map[string]ServerDef `json:"servers"`
64+ Allow []string `json:"allow"`
65+ }{g.Servers, allow})
66+ sum := sha256.Sum256(body)
67+ return "sha256:" + hex.EncodeToString(sum[:])
68+ }
69+
70+ // Describe is the grant as lines for a trust prompt: each server's command
71+ // line in full, since that is the thing being agreed to, and each rule.
72+ func (g Grant) Describe() []string {
73+ var lines []string
74+ if len(g.Servers) > 0 {
75+ lines = append(lines, "start these MCP servers, from "+ProjectMCP+":")
76+ for _, name := range sortedNames(g.Servers) {
77+ d := g.Servers[name]
78+ line := " " + name + ": " + strings.Join(append([]string{d.Command}, d.Args...), " ")
79+ if len(d.Env) > 0 {
80+ line += " (env: " + strings.Join(sortedNames(d.Env), ", ") + ")"
81+ }
82+ lines = append(lines, line)
83+ }
84+ }
85+ if len(g.Allow) > 0 {
86+ lines = append(lines, "run these without asking, from "+strings.Join(g.AllowFrom, " and ")+":")
87+ for _, r := range g.Allow {
88+ lines = append(lines, " "+r)
89+ }
90+ }
91+ return lines
92+ }
93+
94+ // TrustFilename is the record of trust decisions, beside Codey's config.
95+ const TrustFilename = "trust.json"
96+
97+ // TrustStore remembers, per working root, whether the user trusted that
98+ // project's Claude Code files — and which version of them.
99+ //
100+ // It lives in the user's config directory, not in the project's .codey/
101+ // directory with the rest of the per-repository state. That is the whole
102+ // point of it: anything inside the working tree can arrive with a clone, and a
103+ // repository that shipped its own .codey/trust.json would be approving its own
104+ // servers. The fingerprint is no protection there, because whoever wrote the
105+ // .mcp.json can compute it. Only a file the repository cannot reach can say
106+ // what the user decided.
107+ type TrustStore struct {
108+ Path string
109+ }
110+
111+ // DefaultTrustStore is the store beside the config file, wherever
112+ // XDG_CONFIG_HOME puts that.
113+ func DefaultTrustStore(configPath string) TrustStore {
114+ return TrustStore{Path: filepath.Join(filepath.Dir(configPath), TrustFilename)}
115+ }
116+
117+ type trustFile struct {
118+ Version int `json:"version"`
119+ Projects map[string]trustEntry `json:"projects"`
120+ }
121+
122+ type trustEntry struct {
123+ Fingerprint string `json:"fingerprint"`
124+ Trusted bool `json:"trusted"`
125+ Decided time.Time `json:"decided"`
126+ }
127+
128+ // Lookup reports whether a decision has been made about this root with this
129+ // fingerprint, and what it was. A decision about a different fingerprint is
130+ // no decision: the files have changed since.
131+ func (s TrustStore) Lookup(root, fingerprint string) (decided, trusted bool, err error) {
132+ f, err := s.read()
133+ if err != nil {
134+ return false, false, err
135+ }
136+ e, ok := f.Projects[filepath.Clean(root)]
137+ if !ok || e.Fingerprint != fingerprint {
138+ return false, false, nil
139+ }
140+ return true, e.Trusted, nil
141+ }
142+
143+ // Record saves a decision, replacing whatever was decided about this root
144+ // before. A refusal is recorded too, so that saying no is not a question asked
145+ // again on every start; a changed file asks again either way.
146+ func (s TrustStore) Record(root, fingerprint string, trusted bool) error {
147+ f, err := s.read()
148+ if err != nil {
149+ return err
150+ }
151+ if f.Projects == nil {
152+ f.Projects = map[string]trustEntry{}
153+ }
154+ f.Version = 1
155+ f.Projects[filepath.Clean(root)] = trustEntry{Fingerprint: fingerprint, Trusted: trusted, Decided: time.Now().UTC()}
156+ body, err := json.MarshalIndent(f, "", " ")
157+ if err != nil {
158+ return err
159+ }
160+ body = append(body, '\n')
161+ if err := os.MkdirAll(filepath.Dir(s.Path), 0o755); err != nil {
162+ return fmt.Errorf("trust: %w", err)
163+ }
164+ // A temporary file renamed over the target, as config.Save does: a write
165+ // cut short must not lose every other project's decision.
166+ tmp, err := os.CreateTemp(filepath.Dir(s.Path), TrustFilename+".*")
167+ if err != nil {
168+ return fmt.Errorf("trust: %w", err)
169+ }
170+ defer os.Remove(tmp.Name())
171+ if _, err := tmp.Write(body); err != nil {
172+ tmp.Close()
173+ return fmt.Errorf("trust: %w", err)
174+ }
175+ if err := tmp.Close(); err != nil {
176+ return fmt.Errorf("trust: %w", err)
177+ }
178+ return os.Rename(tmp.Name(), s.Path)
179+ }
180+
181+ func (s TrustStore) read() (trustFile, error) {
182+ var f trustFile
183+ raw, err := os.ReadFile(s.Path)
184+ if errors.Is(err, fs.ErrNotExist) {
185+ return f, nil
186+ }
187+ if err != nil {
188+ return f, fmt.Errorf("trust: %w", err)
189+ }
190+ if err := json.Unmarshal(raw, &f); err != nil {
191+ return f, fmt.Errorf("trust: %s is not valid JSON: %w", s.Path, err)
192+ }
193+ return f, nil
194+ }
+114−0internal/ccsettings/trust_test.goaddedView File
@@ -0,0 +1,114 @@
1+ package ccsettings
2+
3+ import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ )
8+
9+ func TestFingerprint(t *testing.T) {
10+ base := Grant{
11+ Servers: map[string]ServerDef{"a": {Command: "x", Env: map[string]string{"K": "1", "J": "2"}}, "b": {Command: "y"}},
12+ Allow: []string{"Bash(make:*)", "Edit"},
13+ }
14+ same := Grant{
15+ Servers: map[string]ServerDef{"b": {Command: "y"}, "a": {Command: "x", Env: map[string]string{"J": "2", "K": "1"}}},
16+ Allow: []string{"Edit", "Bash(make:*)"},
17+ }
18+ if base.Fingerprint() != same.Fingerprint() {
19+ t.Error("the same definitions in another order should be the same fingerprint")
20+ }
21+ changed := []Grant{
22+ {Servers: map[string]ServerDef{"a": {Command: "x", Env: map[string]string{"K": "1", "J": "2"}}, "b": {Command: "y", Args: []string{"--evil"}}}, Allow: base.Allow},
23+ {Servers: map[string]ServerDef{"a": {Command: "x", Env: map[string]string{"K": "1", "J": "2"}}}, Allow: base.Allow},
24+ {Servers: base.Servers, Allow: []string{"Bash(make:*)", "Edit", "Bash"}},
25+ {Servers: base.Servers, Allow: []string{"Bash(make:*)", "Edit"}, AllowFrom: []string{"elsewhere"}},
26+ }
27+ for i, g := range changed[:3] {
28+ if g.Fingerprint() == base.Fingerprint() {
29+ t.Errorf("change %d did not change the fingerprint", i)
30+ }
31+ }
32+ // Which file a rule came from is not part of what is trusted.
33+ if changed[3].Fingerprint() != base.Fingerprint() {
34+ t.Error("AllowFrom should not affect the fingerprint")
35+ }
36+ if !(Grant{}).Empty() || base.Empty() {
37+ t.Error("Empty is wrong")
38+ }
39+ }
40+
41+ func TestNewGrantTakesOnlyProjectScope(t *testing.T) {
42+ mcp := []MCPFile{
43+ {Scope: ScopeLocal, Servers: map[string]ServerDef{"l": {Command: "l"}}},
44+ {Scope: ScopeProject, Servers: map[string]ServerDef{"p": {Command: "p"}}},
45+ {Scope: ScopeUser, Servers: map[string]ServerDef{"u": {Command: "u"}}},
46+ }
47+ perms := []Permissions{
48+ {Scope: ScopeUser, Allow: []string{"Bash"}},
49+ {Scope: ScopeProject, Shown: ".claude/settings.json", Allow: []string{"Edit"}, Deny: []string{"Read(.env)"}},
50+ {Scope: ScopeLocal, Allow: []string{"Write"}},
51+ }
52+ g := NewGrant(mcp, perms)
53+ if len(g.Servers) != 1 || g.Servers["p"].Command != "p" {
54+ t.Errorf("servers %v", g.Servers)
55+ }
56+ if len(g.Allow) != 1 || g.Allow[0] != "Edit" || g.AllowFrom[0] != ".claude/settings.json" {
57+ t.Errorf("allow %v from %v", g.Allow, g.AllowFrom)
58+ }
59+ if len(g.Describe()) != 4 {
60+ t.Errorf("describe: %q", g.Describe())
61+ }
62+ }
63+
64+ func TestTrustStore(t *testing.T) {
65+ s := TrustStore{Path: filepath.Join(t.TempDir(), "codey", TrustFilename)}
66+ root := "/src/proj"
67+
68+ if decided, _, err := s.Lookup(root, "fp1"); decided || err != nil {
69+ t.Fatalf("a fresh store decided=%v err=%v", decided, err)
70+ }
71+ if err := s.Record(root, "fp1", true); err != nil {
72+ t.Fatal(err)
73+ }
74+ if decided, trusted, _ := s.Lookup(root, "fp1"); !decided || !trusted {
75+ t.Error("the recorded decision was not found")
76+ }
77+ // Changed definitions are a new question.
78+ if decided, _, _ := s.Lookup(root, "fp2"); decided {
79+ t.Error("a decision about other definitions counted")
80+ }
81+ // Another checkout is another question.
82+ if decided, _, _ := s.Lookup("/src/other", "fp1"); decided {
83+ t.Error("a decision about another root counted")
84+ }
85+ // A refusal is remembered as a refusal.
86+ if err := s.Record(root, "fp2", false); err != nil {
87+ t.Fatal(err)
88+ }
89+ if decided, trusted, _ := s.Lookup(root, "fp2"); !decided || trusted {
90+ t.Error("the refusal was not remembered")
91+ }
92+ if decided, _, _ := s.Lookup(root, "fp1"); decided {
93+ t.Error("the old decision should have been replaced")
94+ }
95+ // Other roots' decisions survive a write.
96+ s.Record("/src/other", "x", true)
97+ if decided, _, _ := s.Lookup(root, "fp2"); !decided {
98+ t.Error("recording one root lost another")
99+ }
100+ }
101+
102+ // A store that cannot be read is an error, not "nobody decided": the caller
103+ // treats it as untrusted, and must not overwrite it.
104+ func TestTrustStoreCorrupt(t *testing.T) {
105+ path := filepath.Join(t.TempDir(), TrustFilename)
106+ os.WriteFile(path, []byte("{"), 0o644)
107+ s := TrustStore{Path: path}
108+ if _, _, err := s.Lookup("/r", "fp"); err == nil {
109+ t.Error("want an error")
110+ }
111+ if err := s.Record("/r", "fp", true); err == nil {
112+ t.Error("recording over a corrupt store should fail rather than replace it")
113+ }
114+ }