Vectrum
Sign In

Merge claude code settings: .mcp.json, permissions, CLAUDE.md, and the trust they need

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

# Conflicts:
#	README.md
Jjonicommitted 2 parents 20a95a6 e750ed4commitbc84208

Showing 25 changed files with 3565 additions and 7 deletions.

Merge commit — showing changes against the first parent.

+202−3README.mdView File
@@ -119,7 +119,8 @@ would break your Claude Code login.
119119 Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`,
120120 `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`,
121121 `-no-tui`, `-no-skills`, `-no-agents`, `-no-web`, `-no-hooks`,
122- `-no-diagnostics`, `-no-mcp`, `-claude-code-auth`, `-no-claude-code-auth`.
122+ `-no-diagnostics`, `-no-mcp`, `-claude-code-auth`, `-no-claude-code-auth`,
123+ `-trust-project`.
123124
124125 ### Flags and thresholds
125126
@@ -965,6 +966,7 @@ rather than the project's.
965966 | Path | What |
966967 | --- | --- |
967968 | `~/.config/codey/config.json` | the answers, written by the wizard and `/settings`; [hooks](#hooks) go here by hand |
969+ | `~/.config/codey/trust.json` | which checkouts' [Claude Code files](#trusting-a-project) you trusted, and which version of them |
968970 | `<repo>/.codey/sessions/` | saved conversations, which is what `-resume` and `/resume` read |
969971 | `<repo>/.codey/events.jsonl` | the event log METRICS.md is built from |
970972 | `<repo>/.codey/history` | what has been typed at the prompt here, which `↑` walks back through |
@@ -1031,6 +1033,114 @@ The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse t
10311033 their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20,
10321034 and any key skips the one playing.
10331035
1036+ ### Claude Code's settings
1037+
1038+ If you already use Claude Code, a project set up for it should not need setting
1039+ up a second time. Codey reads three kinds of thing from Claude Code's files —
1040+ [its MCP servers](#claude-codes-servers), its permission rules, and its
1041+ CLAUDE.md files — and none of it is copied anywhere or written back: change it
1042+ in Claude Code and the next start here sees the change.
1043+
1044+ **Permissions.** The `permissions` block of three settings files, in the order
1045+ Claude Code reads them:
1046+
1047+ | File | Whose | `deny` / `ask` | `allow` | `defaultMode` |
1048+ | --- | --- | --- | --- | --- |
1049+ | `~/.claude/settings.json` | yours, every project | honoured | honoured | `acceptEdits` only |
1050+ | `.claude/settings.json` | the repository's, committed | honoured | only once [trusted](#trusting-a-project) | ignored |
1051+ | `.claude/settings.local.json` | yours, this project, git-ignored | honoured | honoured | `acceptEdits` only |
1052+
1053+ The rule underneath that table is who wrote the file. A deny or an ask rule can
1054+ only make the agent do less or ask more, and nobody comes to harm from that, so
1055+ a repository may add as many as it likes. An allow rule is the opposite: it is
1056+ a decision that something runs without you being asked, and a decision written
1057+ into a repository was made by whoever wrote the repository. The shared file's
1058+ allow rules therefore wait for the same trust question as its `.mcp.json`, and
1059+ until you answer it they are listed and ignored. A `settings.local.json` that
1060+ git turns out to be tracking arrived with the clone, whatever its name says,
1061+ and is treated as shared.
1062+
1063+ The rules act before anything else in the approval policy. A deny holds in
1064+ every mode, `-yolo` included — that is what writing it down is for — and it
1065+ holds for the read-only tools too, since `Read(.env)` is a rule about reading.
1066+ An ask rule puts the call to you even when the mode would have let it through,
1067+ and even when you said "always this tool" earlier in the session. An allow rule
1068+ lets a call through without a prompt. Between them deny beats ask beats allow,
1069+ as in Claude Code, so no allow rule anywhere argues a call past a deny. The
1070+ denial the model reads names the rule and the file, so it can tell you which
1071+ line to change instead of retrying. `/allowed` lists every rule under the file
1072+ it came from, and says which are in force and why the others are not.
1073+
1074+ Claude Code's names map onto Codey's: `Bash` is `bash`, `Read` is `read_file`
1075+ (and, as best it can, `glob` and `grep`), `Edit` is both `edit_file` and
1076+ `write_file`, `MultiEdit` is `edit_file`, `Write` is `write_file`, `Glob`,
1077+ `Grep`, `WebFetch`, `WebSearch` and `TodoWrite` are the obvious ones, and
1078+ `mcp__server` and `mcp__server__tool` are unchanged. A rule naming a tool Codey
1079+ does not have, or written in a form it does not understand, is reported at
1080+ startup and takes no part. Paths follow Claude Code's conventions: `//path` is
1081+ absolute, `~/path` is under your home, `/path` is under the project root, and
1082+ anything else is relative to it, with a bare name such as `.env` matching at
1083+ any depth the way `.gitignore` would. Links are followed, so a rule about
1084+ `.env` also catches a symlink to it.
1085+
1086+ `Bash(npm run test:*)` is matched one simple command at a time, using the same
1087+ parser as the approval prompt. An allow rule covers `npm run test && rm -rf ~`
1088+ only if something also allows `rm`; a deny rule catches the line if it catches
1089+ any part of it. A command the parser cannot see through — `$(...)`, backticks,
1090+ `bash -c`, a variable in command position — never matches an allow rule, and
1091+ where a deny rule cannot be ruled out for it, the call is asked about rather
1092+ than run. So is anything that does more than its words say: `PATH=. git
1093+ status`, or `echo hi > ~/.bashrc`, is not covered by an allow rule for `git` or
1094+ `echo`. None of this makes shell allowlisting a security boundary — see
1095+ [Safety](#safety) — but it does stop the commonest way a prefix rule says yes to
1096+ something nobody wrote down.
1097+
1098+ `defaultMode: "acceptEdits"` in a file of yours lets file edits through, as
1099+ Claude Code's mode of that name does. `default` is the default. The other modes
1100+ are ignored with a notice, from every file: `bypassPermissions` in particular,
1101+ because in Codey the way to stop being asked is `-yolo`, typed on the command
1102+ line for one run, and neither a file of yours nor a repository's gets to say it
1103+ for you.
1104+
1105+ **CLAUDE.md.** The files Claude Code loads into every conversation are loaded
1106+ into this one's system prompt, at startup, in Claude Code's order:
1107+ `~/.claude/CLAUDE.md`, then — from the top of the project down to the working
1108+ root — each directory's `CLAUDE.md`, `.claude/CLAUDE.md` and `CLAUDE.local.md`.
1109+ The top is the nearest directory with a `.git` in it, or your home directory if
1110+ there is none on the way; a root that is in neither is a project of one
1111+ directory, because walking up to `/` would read whatever happened to be lying
1112+ in `/tmp`. Later files are more specific and the prompt says they win.
1113+ `@path/to/file` imports are followed, relative to the file they appear in and up
1114+ to five deep, skipping anything in a code block. A project's files may import
1115+ only from the project, and that is checked where symlinks lead: a repository's
1116+ CLAUDE.md that could import `@~/.aws/credentials` would be a repository reading
1117+ your credentials into a conversation that is then sent to the model. Your own
1118+ `~/.claude/CLAUDE.md` and `CLAUDE.local.md` may import from your home
1119+ directory. The whole thing is read once per session, so the cached prompt
1120+ prefix stays the same from request to request; restart to pick up an edit.
1121+ `AGENTS.md` is not loaded this way, but the agent is still told to read one if
1122+ the repository has it, as before, and a CLAUDE.md that says `@AGENTS.md` brings
1123+ it in.
1124+
1125+ **What is not carried over**, so nobody has to find out the hard way:
1126+
1127+ - Hooks in Claude Code's format (`PreToolUse`, `PostToolUse` and the rest).
1128+ Codey's [hooks](#hooks) are a different thing — they watch, they cannot
1129+ veto — and a script written to block a tool call would silently block
1130+ nothing here.
1131+ - `env`, `model`, `apiKeyHelper`, `statusLine`, `outputStyle`, `cleanupPeriodDays`
1132+ and the other non-permission settings. Codey has its own config for the ones
1133+ it has an equivalent of.
1134+ - `permissions.additionalDirectories`: the file tools keep the working root as
1135+ their only root.
1136+ - Claude Code's own record of which `.mcp.json` servers you approved
1137+ (`enabledMcpjsonServers`, `enableAllProjectMcpServers`). It approves by name,
1138+ so a server whose command changed under the same name would still count;
1139+ Codey asks its own question about the exact definitions instead.
1140+ - Managed (enterprise) settings, subdirectory CLAUDE.md files loaded on demand,
1141+ and `.claude/rules/`.
1142+ - Remote MCP servers, which Codey's client does not speak.
1143+
10341144 ## Tools
10351145
10361146 | Tool | Approval | Notes |
@@ -1280,7 +1390,85 @@ Only tools are used: not resources, prompts or sampling, and no HTTP
12801390 transport. Pictures a tool returns are described to the model rather than
12811391 shown. A server's directory is fixed when it starts, so after `/worktree` it
12821392 is still serving the root the session began in. `-no-mcp` starts none of them
1283- for one run.
1393+ for one run — Codey's own and Claude Code's alike.
1394+
1395+ ### Claude Code's servers
1396+
1397+ The servers Claude Code already knows about are started as well, read from
1398+ where Claude Code keeps them rather than copied into Codey's config:
1399+
1400+ | Where | Whose | Started |
1401+ | --- | --- | --- |
1402+ | `~/.claude.json`, under `projects["<root>"].mcpServers` | yours, for this project — what `claude mcp add` writes | always |
1403+ | `.mcp.json` at the working root | the repository's, committed | once you [trust it](#trusting-a-project) |
1404+ | `~/.claude.json`, top-level `mcpServers` | yours, for every project — `claude mcp add -s user` | always |
1405+
1406+ The shape is the one already shown above, `{"mcpServers": {name: {command,
1407+ args, env}}}`, with `"type": "stdio"` accepted and meaning the same thing. A
1408+ server with `"type": "http"` or `"sse"`, or with a `url`, is a remote server;
1409+ Codey's client speaks only stdio, so it is reported at startup and skipped.
1410+ `${VAR}` and `${VAR:-default}` are expanded in the command, the arguments and
1411+ the env values, as Claude Code expands them — so a `.mcp.json` can say
1412+ `"${DB_URL}"` and leave the secret in your environment. A variable that is
1413+ unset and has no default skips that server with a notice naming it, rather
1414+ than starting it with a hole in its command line.
1415+
1416+ When two places define a server of the same name, Codey's own `mcp_servers`
1417+ win, and after that the table is read top to bottom: your per-project servers,
1418+ then the repository's, then your global ones. That is Claude Code's own order,
1419+ so a name means the same program in both. Each clash is reported once, naming
1420+ the one that won, because a server that quietly turned out to be a different
1421+ program is not something anyone thinks to debug.
1422+
1423+ #### Trusting a project
1424+
1425+ A `.mcp.json` comes from the repository. Starting a server listed there means
1426+ running a command — with your environment, your credentials, your network —
1427+ that whoever wrote the repository chose, before the agent has done anything
1428+ and outside every approval prompt. So, like Claude Code, Codey asks first. The
1429+ first interactive start in a checkout whose `.mcp.json` or shared
1430+ `.claude/settings.json` [allow rules](#claude-codes-settings) would grant
1431+ something prints, before the TUI opens:
1432+
1433+ ```
1434+ Claude Code's configuration for /home/you/src/thing would:
1435+
1436+ start these MCP servers, from .mcp.json:
1437+ db: npx -y @acme/db-mcp --url ${DB_URL} (env: DB_TOKEN)
1438+ run these without asking, from .claude/settings.json:
1439+ Bash(make:*)
1440+
1441+ These files come from the repository, not from you: trusting them lets it
1442+ choose commands this machine runs. Your answer is remembered until they change.
1443+ Trust them for this checkout? [y/N]
1444+ ```
1445+
1446+ One question covers both files, because it is one question: do you trust what
1447+ this repository says about itself. The full command line is shown because that
1448+ is the thing being agreed to. The answer — yes or no — is remembered for this
1449+ checkout against a fingerprint of exactly those definitions, as written, before
1450+ any `${VAR}` is expanded. A new server, a changed argument or one more allow
1451+ rule is a different fingerprint, and the question comes back; nothing else
1452+ does. A refusal is remembered too, so saying no once is not a question at every
1453+ start.
1454+
1455+ The record is kept in `~/.config/codey/trust.json`, not in the project's
1456+ `.codey/` directory with the rest of the per-repository state. That is the
1457+ point of it: anything inside the working tree can arrive with a clone, and a
1458+ repository that shipped its own record of being trusted would be approving its
1459+ own servers. The fingerprint would be no defence there — whoever wrote the
1460+ `.mcp.json` can compute it. Only a file the repository cannot reach can say what
1461+ you decided.
1462+
1463+ Where nobody can be asked — `-p`, `codey agent stdio`, a piped stdin — an
1464+ undecided project's servers are not started and its allow rules are ignored,
1465+ with a line on stderr saying so. A checkout you have already trusted needs
1466+ nothing more. `-trust-project` says yes for one run without asking and without
1467+ recording anything, which is what a CI job against a repository you control
1468+ wants. Servers in `~/.claude.json` never ask: you put them there, as you put
1469+ Codey's own in its config. Once started, every server's tool calls go through
1470+ the approval prompts like any other, unless an allow rule such as `mcp__db`
1471+ says otherwise.
12841472
12851473 ## Hooks
12861474
@@ -1533,6 +1721,14 @@ interpreter with `-c` cannot be analysed at all, so it is marked opaque and
15331721 allowlists for shell commands do not work; this exists to make the prompt
15341722 specific, not to make the decision for you.
15351723
1724+ [Claude Code's permission rules](#claude-codes-settings) sit in front of all
1725+ of that. Its deny rules hold in every mode, `-yolo` included, which makes them
1726+ the one part of this layer you can lean on: a call they refuse does not run.
1727+ Its allow rules are matched with the same parser, one simple command at a
1728+ time, and an opaque command matches none of them — but the warning above
1729+ applies to them with full force. `Bash(find:*)` allows `find . -exec rm {} +`,
1730+ and no parser will tell you otherwise.
1731+
15361732 Hooks sit outside all three layers, deliberately: a command in `"hooks"` runs
15371733 on every event it is attached to, without a prompt, because you put it in your
15381734 own config rather than the agent asking for it. The path jail is what keeps the
@@ -1541,7 +1737,9 @@ working root you would sensibly point Codey at — but the flip side is that
15411737 anything able to write that file has arranged to run commands here. `-no-hooks`
15421738 is the switch that says no for one run. MCP servers are the same bargain: each
15431739 is a program started with your environment when the session opens, and only
1544- its tool calls go through the prompts. `-no-mcp` is theirs.
1740+ its tool calls go through the prompts. `-no-mcp` is theirs. The one kind of
1741+ server that does not come from a file you wrote — a repository's `.mcp.json` —
1742+ waits for you to [trust it](#trusting-a-project) first.
15451743
15461744 **A sandbox.** This is the layer that actually contains a mistake, and the
15471745 Codey cannot provide it for you. The honest recipe on Linux:
@@ -1581,6 +1779,7 @@ internal/websearch the search command, the URL jail, HTML to text
15811779 internal/mcp MCP client: starts the configured servers, offers their tools
15821780 internal/config saved defaults: model, effort, approvals, web search, theme, diagnostics
15831781 internal/approval allow / deny / ask, with shell analysis
1782+ internal/ccsettings Claude Code's MCP servers, permission rules and CLAUDE.md, and who may grant what
15841783 internal/ask questions the agent puts to you mid-task, and the tool for it
15851784 internal/hooks the lifecycle, published: fan-out, and the config's commands
15861785 internal/herdr reporting agent state to the herdr pane Codey is running in
+15−0cmd/codey/agentcmd.goView File
@@ -25,6 +25,7 @@ import (
2525
2626 "github.com/johannSo/codey/internal/acp"
2727 "github.com/johannSo/codey/internal/agent"
28+ "github.com/johannSo/codey/internal/approval"
2829 "github.com/johannSo/codey/internal/llm"
2930 "github.com/johannSo/codey/internal/metrics"
3031 "github.com/johannSo/codey/internal/serve"
@@ -214,6 +215,12 @@ type acpDeps struct {
214215 Base agent.Options
215216 // Tools is main's factory: a root in, a tool-set builder out.
216217 Tools func(root string) func(*agent.State, *metrics.Logger) *tools.Registry
218+ // Rules and Instructions are Claude Code's permission rules and CLAUDE.md
219+ // files for a root. Each session may be rooted somewhere else, so they
220+ // are looked up per session rather than taken from startup. Nil for
221+ // neither.
222+ Rules func(root string) approval.RuleSet
223+ Instructions func(root string) string
217224 }
218225
219226 // serveACP runs the agent over stdin and stdout until the client hangs up.
@@ -250,6 +257,14 @@ func serveACP(ctx context.Context, deps acpDeps) error {
250257 // the parent's limits.
251258 newTools := deps.Tools(req.Root)
252259 reg := newTools(req.State, deps.Log)
260+ if deps.Rules != nil && req.Approve != nil {
261+ req.Approve.Rules = deps.Rules(req.Root)
262+ }
263+ base, subBase := base, subBase
264+ if deps.Instructions != nil {
265+ base.Instructions = deps.Instructions(req.Root)
266+ subBase.Instructions = base.Instructions
267+ }
253268
254269 set := serve.AgentSet{}
255270 if !deps.NoAgents {
+265−0cmd/codey/ccconfig.goaddedView File
@@ -0,0 +1,265 @@
1+ package main
2+
3+ // What a Claude Code user's existing configuration contributes to a run: the
4+ // MCP servers it knows about, the permission rules in its settings files, and
5+ // its CLAUDE.md files. The reading and the rules about what is honoured live in
6+ // internal/ccsettings; this file is the wiring — when to ask about trust, how,
7+ // and what to say when the answer is no.
8+
9+ import (
10+ "fmt"
11+ "io"
12+ "os"
13+ "sort"
14+ "strings"
15+ "sync"
16+
17+ "github.com/johannSo/codey/internal/approval"
18+ "github.com/johannSo/codey/internal/ccsettings"
19+ "github.com/johannSo/codey/internal/config"
20+ )
21+
22+ // trustFlag is spelled once, because three messages name it.
23+ const trustFlag = "-trust-project"
24+
25+ // ccOptions is what loading Claude Code's configuration needs from main.
26+ type ccOptions struct {
27+ Home, Root string
28+ // Own is Codey's own mcp_servers, which win any clash.
29+ Own map[string]config.MCPServer
30+ // Trust is -trust-project: use the project's servers and allow rules this
31+ // run, whatever was decided before, without asking.
32+ Trust bool
33+ // NoMCP is -no-mcp. The servers are still part of the trust question —
34+ // the question is about the repository, not about this run — but the
35+ // prompt says they will not start.
36+ NoMCP bool
37+ // Ask puts the trust question to a person and reports the answer, and
38+ // whether there was one. Nil where nobody can answer: -p, agent stdio,
39+ // a piped stdin.
40+ Ask func(root string, lines []string) (trusted, answered bool)
41+ Store ccsettings.TrustStore
42+ // Notice reports something to the user, wherever this run's notices go.
43+ Notice func(format string, args ...any)
44+ // Lookup is the environment ${VAR} expands from.
45+ Lookup func(string) (string, bool)
46+ }
47+
48+ // ccSetup is what came of it.
49+ type ccSetup struct {
50+ // MCP is every server to start, Codey's own included.
51+ MCP map[string]config.MCPServer
52+ // Rules is nil when no settings file had any rules, so the policy is left
53+ // exactly as it would be on a machine without Claude Code.
54+ Rules approval.RuleSet
55+ // Trusted is the trust decision for the root, for reuse by rulesFor.
56+ Trusted bool
57+ }
58+
59+ // loadClaudeCode reads Claude Code's files for the startup root, settles
60+ // whether the project's own files are trusted — asking, if there is someone
61+ // to ask and nothing has been decided about these exact definitions — and
62+ // returns what this run should use.
63+ func loadClaudeCode(o ccOptions) ccSetup {
64+ if o.Lookup == nil {
65+ o.Lookup = os.LookupEnv
66+ }
67+ mcpFiles, errs := ccsettings.LoadMCP(o.Home, o.Root)
68+ for _, err := range errs {
69+ o.Notice("%v", err)
70+ }
71+ perms, errs := ccsettings.LoadPermissions(o.Home, o.Root)
72+ for _, err := range errs {
73+ o.Notice("%v — its deny rules are not in force", err)
74+ }
75+
76+ grant := ccsettings.NewGrant(mcpFiles, perms)
77+ trusted := settleTrust(o, grant)
78+
79+ out := ccSetup{Trusted: trusted}
80+ merged, notes := ccsettings.MergeMCP(o.Own, mcpFiles, trusted, o.Lookup)
81+ out.MCP = merged
82+ if !o.NoMCP {
83+ // A clash or a missing variable is only news if the servers were
84+ // going to start.
85+ for _, n := range notes {
86+ o.Notice("%s", n)
87+ }
88+ }
89+ rules, notes := ccsettings.NewRuleset(o.Home, o.Root, perms, trusted)
90+ for _, n := range notes {
91+ o.Notice("%s", n)
92+ }
93+ if !rules.Empty() {
94+ out.Rules = rules
95+ }
96+ return out
97+ }
98+
99+ // settleTrust decides whether what the project's committed files grant is
100+ // used this run. The flag wins; then a decision already recorded for these
101+ // exact definitions; then the person at the terminal, if there is one. With
102+ // nobody to ask, the answer is no, and the notice says how to say yes.
103+ func settleTrust(o ccOptions, grant ccsettings.Grant) bool {
104+ if grant.Empty() {
105+ return false
106+ }
107+ if o.Trust {
108+ return true
109+ }
110+ fp := grant.Fingerprint()
111+ decided, trusted, err := o.Store.Lookup(o.Root, fp)
112+ if err != nil {
113+ o.Notice("%v — treating this project as not trusted", err)
114+ return false
115+ }
116+ if decided {
117+ if !trusted {
118+ o.Notice("%s — you declined to trust them for this checkout. %s uses them for one run; a change to them asks again", untrustedWhat(grant), trustFlag)
119+ }
120+ return trusted
121+ }
122+ if o.Ask == nil {
123+ o.Notice("%s — they come from the repository, and nobody has said this checkout is trusted. Run once interactively to be asked, or pass %s", untrustedWhat(grant), trustFlag)
124+ return false
125+ }
126+ lines := grant.Describe()
127+ if o.NoMCP && len(grant.Servers) > 0 {
128+ lines = append(lines, "(the servers will not start this run: -no-mcp)")
129+ }
130+ trusted, answered := o.Ask(o.Root, lines)
131+ if !answered {
132+ // Interrupted, or stdin closed: no for now, and the question stays
133+ // open for the next start.
134+ return false
135+ }
136+ if err := o.Store.Record(o.Root, fp, trusted); err != nil {
137+ o.Notice("%v — the answer holds for this run only", err)
138+ }
139+ if !trusted {
140+ o.Notice("%s — you can change your mind with %s", untrustedWhat(grant), trustFlag)
141+ }
142+ return trusted
143+ }
144+
145+ // untrustedWhat names what is being left out, for a notice.
146+ func untrustedWhat(g ccsettings.Grant) string {
147+ var parts []string
148+ if len(g.Servers) > 0 {
149+ names := make([]string, 0, len(g.Servers))
150+ for n := range g.Servers {
151+ names = append(names, n)
152+ }
153+ sort.Strings(names)
154+ parts = append(parts, fmt.Sprintf("the MCP servers in %s (%s) were not started", ccsettings.ProjectMCP, strings.Join(names, ", ")))
155+ }
156+ if len(g.Allow) > 0 {
157+ parts = append(parts, fmt.Sprintf("the allow rules in %s are ignored", strings.Join(g.AllowFrom, " and ")))
158+ }
159+ return strings.Join(parts, ", and ")
160+ }
161+
162+ // askTrustOnTerminal is the trust question, asked on the terminal before the
163+ // TUI takes it over — the same moment Claude Code asks its own. It goes to
164+ // stderr and reads stdin a byte at a time, so nothing typed after the answer
165+ // is swallowed into a buffer the TUI never sees.
166+ func askTrustOnTerminal(in io.Reader, out io.Writer) func(string, []string) (bool, bool) {
167+ return func(root string, lines []string) (bool, bool) {
168+ fmt.Fprintf(out, "\nClaude Code's configuration for %s would:\n\n", root)
169+ for _, l := range lines {
170+ fmt.Fprintln(out, " "+l)
171+ }
172+ fmt.Fprint(out, "\nThese files come from the repository, not from you: trusting them lets it\n"+
173+ "choose commands this machine runs. Your answer is remembered until they change.\n"+
174+ "Trust them for this checkout? [y/N] ")
175+ line, err := readLine(in)
176+ if err != nil {
177+ fmt.Fprintln(out)
178+ return false, false
179+ }
180+ switch strings.ToLower(strings.TrimSpace(line)) {
181+ case "y", "yes":
182+ return true, true
183+ default:
184+ return false, true
185+ }
186+ }
187+ }
188+
189+ // readLine reads up to a newline without reading past it.
190+ func readLine(r io.Reader) (string, error) {
191+ var b strings.Builder
192+ buf := make([]byte, 1)
193+ for {
194+ n, err := r.Read(buf)
195+ if n == 1 {
196+ if buf[0] == '\n' {
197+ return b.String(), nil
198+ }
199+ b.WriteByte(buf[0])
200+ }
201+ if err != nil {
202+ if err == io.EOF && b.Len() > 0 {
203+ return b.String(), nil
204+ }
205+ return "", err
206+ }
207+ }
208+ }
209+
210+ // rulesFor builds the rule set for a root other than the one Codey started
211+ // in — an ACP client hands each thread a directory of its own. Nobody can be
212+ // asked from there, so the project's allow rules count only if trust was
213+ // already recorded for them, or -trust-project was given. Each root is read
214+ // once; a model switch rebuilds the agent, and the same notices twice would
215+ // be noise.
216+ func rulesFor(o ccOptions, startRoot string, start ccSetup) func(root string) approval.RuleSet {
217+ var mu sync.Mutex
218+ cache := map[string]approval.RuleSet{startRoot: start.Rules}
219+ return func(root string) approval.RuleSet {
220+ mu.Lock()
221+ defer mu.Unlock()
222+ if rs, ok := cache[root]; ok {
223+ return rs
224+ }
225+ ro := o
226+ ro.Root, ro.Ask = root, nil
227+ mcpFiles, _ := ccsettings.LoadMCP(o.Home, root)
228+ perms, errs := ccsettings.LoadPermissions(o.Home, root)
229+ for _, err := range errs {
230+ o.Notice("%v — its deny rules are not in force", err)
231+ }
232+ trusted := settleTrust(ro, ccsettings.NewGrant(mcpFiles, perms))
233+ rules, notes := ccsettings.NewRuleset(o.Home, root, perms, trusted)
234+ for _, n := range notes {
235+ o.Notice("%s", n)
236+ }
237+ var rs approval.RuleSet
238+ if !rules.Empty() {
239+ rs = rules
240+ }
241+ cache[root] = rs
242+ return rs
243+ }
244+ }
245+
246+ // instructionsFor reads the CLAUDE.md files for a root, formatted for the
247+ // system prompt, reporting what could not be read.
248+ func instructionsFor(home string, notice func(string, ...any)) func(root string) string {
249+ var mu sync.Mutex
250+ cache := map[string]string{}
251+ return func(root string) string {
252+ mu.Lock()
253+ defer mu.Unlock()
254+ if s, ok := cache[root]; ok {
255+ return s
256+ }
257+ files, errs := ccsettings.LoadInstructions(home, root)
258+ for _, err := range errs {
259+ notice("%v", err)
260+ }
261+ s := ccsettings.FormatInstructions(files)
262+ cache[root] = s
263+ return s
264+ }
265+ }
+274−0cmd/codey/ccconfig_test.goaddedView File
@@ -0,0 +1,274 @@
1+ package main
2+
3+ import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/johannSo/codey/internal/approval"
11+ "github.com/johannSo/codey/internal/ccsettings"
12+ "github.com/johannSo/codey/internal/config"
13+ )
14+
15+ // ccFixture is a home and a project under a temp directory — never the real
16+ // ~/.claude* — with a project .mcp.json, a shared allow rule, and a server of
17+ // the user's own.
18+ type ccFixture struct {
19+ home, root string
20+ store ccsettings.TrustStore
21+ notices []string
22+ asked int
23+ }
24+
25+ func newCCFixture(t *testing.T) *ccFixture {
26+ t.Helper()
27+ base := t.TempDir()
28+ f := &ccFixture{
29+ home: filepath.Join(base, "home"),
30+ root: filepath.Join(base, "home", "proj"),
31+ store: ccsettings.TrustStore{Path: filepath.Join(base, "config", "codey", ccsettings.TrustFilename)},
32+ }
33+ f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"repo-mcp","args":["--root","."]}}}`)
34+ f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)"],"deny":["Bash(rm:*)"]}}`)
35+ f.write(t, f.home, ".claude.json", `{"mcpServers":{"mine":{"command":"my-mcp"}}}`)
36+ return f
37+ }
38+
39+ func (f *ccFixture) write(t *testing.T, dir, rel, body string) {
40+ t.Helper()
41+ path := filepath.Join(dir, rel)
42+ os.MkdirAll(filepath.Dir(path), 0o755)
43+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
44+ t.Fatal(err)
45+ }
46+ }
47+
48+ // opts builds the options; answer nil means nobody can be asked.
49+ func (f *ccFixture) opts(answer *bool) ccOptions {
50+ o := ccOptions{
51+ Home: f.home,
52+ Root: f.root,
53+ Own: map[string]config.MCPServer{"codey-own": {Command: "own"}},
54+ Store: f.store,
55+ Lookup: func(string) (string, bool) { return "", false },
56+ Notice: func(format string, args ...any) { f.notices = append(f.notices, fmt.Sprintf(format, args...)) },
57+ }
58+ if answer != nil {
59+ o.Ask = func(string, []string) (bool, bool) { f.asked++; return *answer, true }
60+ }
61+ return o
62+ }
63+
64+ func (f *ccFixture) said(s string) bool {
65+ for _, n := range f.notices {
66+ if strings.Contains(n, s) {
67+ return true
68+ }
69+ }
70+ return false
71+ }
72+
73+ func allowed(rs approval.RuleSet, cmd string) approval.Verdict {
74+ if rs == nil {
75+ return approval.NoVerdict
76+ }
77+ return rs.Check("bash", []byte(fmt.Sprintf(`{"command":%q}`, cmd))).Verdict
78+ }
79+
80+ func yes() *bool { v := true; return &v }
81+ func no() *bool { v := false; return &v }
82+
83+ // Headless — -p, agent stdio — nothing from the repository is used, the user's
84+ // own servers still start, the repository's deny rules still hold, and the
85+ // notice names the flag that says otherwise.
86+ func TestUntrustedHeadless(t *testing.T) {
87+ f := newCCFixture(t)
88+ got := loadClaudeCode(f.opts(nil))
89+ if _, ok := got.MCP["repo-tool"]; ok {
90+ t.Error("an untrusted project server would start")
91+ }
92+ for _, name := range []string{"mine", "codey-own"} {
93+ if _, ok := got.MCP[name]; !ok {
94+ t.Errorf("%s should start: it is the user's own", name)
95+ }
96+ }
97+ if v := allowed(got.Rules, "make build"); v != approval.NoVerdict {
98+ t.Errorf("an untrusted project allow rule was honoured: %v", v)
99+ }
100+ if v := allowed(got.Rules, "rm -rf x"); v != approval.VerdictDeny {
101+ t.Errorf("the project's deny rule should hold untrusted: %v", v)
102+ }
103+ if !f.said(trustFlag) || !f.said("repo-tool") {
104+ t.Errorf("notices %q should name the server and %s", f.notices, trustFlag)
105+ }
106+ }
107+
108+ func TestTrustFlag(t *testing.T) {
109+ f := newCCFixture(t)
110+ o := f.opts(no())
111+ o.Trust = true
112+ got := loadClaudeCode(o)
113+ if _, ok := got.MCP["repo-tool"]; !ok || f.asked != 0 {
114+ t.Errorf("-trust-project should start the project's servers without asking (asked %d)", f.asked)
115+ }
116+ if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow {
117+ t.Errorf("-trust-project should honour the project's allow rules: %v", v)
118+ }
119+ // The flag is for one run: nothing is recorded.
120+ if decided, _, _ := f.store.Lookup(f.root, ccsettings.NewGrant(mustMCP(t, f), mustPerms(t, f)).Fingerprint()); decided {
121+ t.Error("the flag should not record a decision")
122+ }
123+ }
124+
125+ func TestTrustIsAskedOnceAndRemembered(t *testing.T) {
126+ f := newCCFixture(t)
127+ got := loadClaudeCode(f.opts(yes()))
128+ if f.asked != 1 {
129+ t.Fatalf("asked %d times, want 1", f.asked)
130+ }
131+ if _, ok := got.MCP["repo-tool"]; !ok {
132+ t.Error("a trusted project server should start")
133+ }
134+ if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow {
135+ t.Errorf("a trusted project allow rule should be honoured: %v", v)
136+ }
137+
138+ // The next start, interactive or not, does not ask again.
139+ got = loadClaudeCode(f.opts(yes()))
140+ if f.asked != 1 {
141+ t.Errorf("asked again for the same definitions")
142+ }
143+ got = loadClaudeCode(f.opts(nil))
144+ if _, ok := got.MCP["repo-tool"]; !ok {
145+ t.Error("a recorded trust should hold for a headless run")
146+ }
147+
148+ // A changed .mcp.json is a new question.
149+ f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"curl","args":["evil.example"]}}}`)
150+ got = loadClaudeCode(f.opts(nil))
151+ if _, ok := got.MCP["repo-tool"]; ok {
152+ t.Error("a changed server started on the old trust")
153+ }
154+ loadClaudeCode(f.opts(yes()))
155+ if f.asked != 2 {
156+ t.Errorf("a changed .mcp.json should ask again (asked %d)", f.asked)
157+ }
158+
159+ // So is a changed allow list.
160+ f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)","Bash"]}}`)
161+ got = loadClaudeCode(f.opts(nil))
162+ if v := allowed(got.Rules, "make build"); v != approval.NoVerdict {
163+ t.Errorf("a changed allow list kept the old trust: %v", v)
164+ }
165+ }
166+
167+ func TestDeclinedTrustIsRemembered(t *testing.T) {
168+ f := newCCFixture(t)
169+ got := loadClaudeCode(f.opts(no()))
170+ if _, ok := got.MCP["repo-tool"]; ok {
171+ t.Error("a declined project server would start")
172+ }
173+ f.notices = nil
174+ loadClaudeCode(f.opts(yes()))
175+ if f.asked != 1 {
176+ t.Errorf("a refusal should not be asked again (asked %d)", f.asked)
177+ }
178+ if !f.said("declined") || !f.said(trustFlag) {
179+ t.Errorf("notices %q should say it was declined and name %s", f.notices, trustFlag)
180+ }
181+ }
182+
183+ // Nothing to trust is nothing to ask.
184+ func TestNoProjectFilesNoQuestion(t *testing.T) {
185+ f := newCCFixture(t)
186+ os.Remove(filepath.Join(f.root, ".mcp.json"))
187+ f.write(t, f.root, ".claude/settings.json", `{"permissions":{"deny":["Bash(rm:*)"]}}`)
188+ got := loadClaudeCode(f.opts(yes()))
189+ if f.asked != 0 {
190+ t.Error("asked about a project that grants nothing")
191+ }
192+ if v := allowed(got.Rules, "rm x"); v != approval.VerdictDeny {
193+ t.Errorf("deny = %v", v)
194+ }
195+ }
196+
197+ // A machine without Claude Code leaves the policy exactly as it was.
198+ func TestNoClaudeCodeNoRules(t *testing.T) {
199+ base := t.TempDir()
200+ o := ccOptions{Home: filepath.Join(base, "h"), Root: filepath.Join(base, "r"), Notice: func(string, ...any) {}, Lookup: func(string) (string, bool) { return "", false }}
201+ got := loadClaudeCode(o)
202+ if got.Rules != nil {
203+ t.Errorf("rules = %#v, want nil", got.Rules)
204+ }
205+ }
206+
207+ // A root other than the startup one — an ACP thread in a worktree — is never
208+ // asked about, and uses only what was already trusted.
209+ func TestRulesForAnotherRoot(t *testing.T) {
210+ f := newCCFixture(t)
211+ start := loadClaudeCode(f.opts(nil))
212+ other := filepath.Join(f.home, "other")
213+ f.write(t, other, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)"]}}`)
214+ o := f.opts(yes())
215+ lookup := rulesFor(o, f.root, start)
216+ if v := allowed(lookup(other), "make"); v != approval.NoVerdict || f.asked != 0 {
217+ t.Errorf("verdict %v, asked %d: another root must not be asked about", v, f.asked)
218+ }
219+ g := ccsettings.NewGrant(nil, []ccsettings.Permissions{{Scope: ccsettings.ScopeProject, Allow: []string{"Bash(make:*)"}}})
220+ f.store.Record(other, g.Fingerprint(), true)
221+ lookup = rulesFor(o, f.root, start)
222+ if v := allowed(lookup(other), "make"); v != approval.VerdictAllow {
223+ t.Errorf("a recorded trust for the other root should count: %v", v)
224+ }
225+ }
226+
227+ func TestAskTrustOnTerminal(t *testing.T) {
228+ tests := []struct {
229+ in string
230+ trusted, answered bool
231+ }{
232+ {"y\n", true, true},
233+ {"YES\n", true, true},
234+ {"n\n", false, true},
235+ {"\n", false, true},
236+ {"", false, false},
237+ }
238+ for _, tc := range tests {
239+ var out strings.Builder
240+ ask := askTrustOnTerminal(strings.NewReader(tc.in), &out)
241+ trusted, answered := ask("/src/proj", []string{"start these MCP servers, from .mcp.json:", " db: db-mcp"})
242+ if trusted != tc.trusted || answered != tc.answered {
243+ t.Errorf("%q: got %v %v, want %v %v", tc.in, trusted, answered, tc.trusted, tc.answered)
244+ }
245+ if !strings.Contains(out.String(), "db: db-mcp") || !strings.Contains(out.String(), "[y/N]") {
246+ t.Errorf("prompt did not show the definitions:\n%s", out.String())
247+ }
248+ }
249+ }
250+
251+ // The answer is read without reading past it: whatever is typed next belongs
252+ // to the TUI.
253+ func TestReadLineStopsAtTheNewline(t *testing.T) {
254+ r := strings.NewReader("y\nnext")
255+ line, err := readLine(r)
256+ if line != "y" || err != nil {
257+ t.Fatalf("%q %v", line, err)
258+ }
259+ rest := make([]byte, 8)
260+ n, _ := r.Read(rest)
261+ if string(rest[:n]) != "next" {
262+ t.Errorf("rest = %q", rest[:n])
263+ }
264+ }
265+
266+ func mustMCP(t *testing.T, f *ccFixture) []ccsettings.MCPFile {
267+ files, _ := ccsettings.LoadMCP(f.home, f.root)
268+ return files
269+ }
270+
271+ func mustPerms(t *testing.T, f *ccFixture) []ccsettings.Permissions {
272+ p, _ := ccsettings.LoadPermissions(f.home, f.root)
273+ return p
274+ }
+41−2cmd/codey/main.goView File
@@ -17,6 +17,7 @@ import (
1717 "github.com/johannSo/codey/internal/agent"
1818 "github.com/johannSo/codey/internal/approval"
1919 "github.com/johannSo/codey/internal/ask"
20+ "github.com/johannSo/codey/internal/ccsettings"
2021 "github.com/johannSo/codey/internal/claudecode"
2122 "github.com/johannSo/codey/internal/config"
2223 "github.com/johannSo/codey/internal/diag"
@@ -95,6 +96,7 @@ func run() error {
9596 noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr")
9697 noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it")
9798 noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered")
99+ trustProject := flag.Bool(strings.TrimPrefix(trustFlag, "-"), false, "use this project's .mcp.json servers and the allow rules in its .claude/settings.json for one run, without asking")
98100
99101 // Read the line before the flag package does. Three invocations belong to
100102 // a program driving Codey rather than to a person — see agentcmd.go — and
@@ -376,9 +378,34 @@ func run() error {
376378 // in and stay there across /worktree, since a server's working directory
377379 // is fixed when it starts. A server that will not start is reported and
378380 // left out; the session goes on without its tools.
381+ // Claude Code's configuration for this root: its MCP servers merged under
382+ // Codey's own, and its permission rules. The project's committed files
383+ // are used only if this checkout is trusted, which is asked here — on the
384+ // terminal, before the TUI owns it — when there is a person to ask and
385+ // nothing has been decided about these exact definitions. -p and agent
386+ // runs are not asked; see ccconfig.go.
387+ ccOpts := ccOptions{
388+ Home: home,
389+ Root: root,
390+ Own: cfg.MCPServers,
391+ Trust: *trustProject,
392+ NoMCP: *noMCP,
393+ Notice: notice,
394+ }
395+ if cfgPath, err := config.Path(); err == nil {
396+ ccOpts.Store = ccsettings.DefaultTrustStore(cfgPath)
397+ }
398+ if inv.Kind == invNormal && *prompt == "" && interactive() {
399+ ccOpts.Ask = askTrustOnTerminal(os.Stdin, os.Stderr)
400+ }
401+ cc := loadClaudeCode(ccOpts)
402+ instructions := instructionsFor(home, notice)
403+
379404 var mcpTools []tools.Tool
380- if len(cfg.MCPServers) > 0 && !*noMCP {
381- ts, closer, errs := mcp.Connect(context.Background(), root, cfg)
405+ if len(cc.MCP) > 0 && !*noMCP {
406+ mcpCfg := cfg
407+ mcpCfg.MCPServers = cc.MCP
408+ ts, closer, errs := mcp.Connect(context.Background(), root, mcpCfg)
382409 defer closer.Close()
383410 for _, err := range errs {
384411 notice("%v", err)
@@ -448,6 +475,7 @@ func run() error {
448475 render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
449476 approver := &ui.Approver{In: in, Out: os.Stdout}
450477 policy := approval.New(approver.Prompt)
478+ policy.Rules = cc.Rules
451479
452480 // Questions go to the line front end until a TUI takes them over in
453481 // tui.Run. The tool belongs to the session's own agent and is registered
@@ -479,6 +507,7 @@ func run() error {
479507 Skills: found,
480508 ServerTools: serverTools,
481509 SystemPrefix: systemPrefix,
510+ Instructions: instructions(root),
482511 }
483512
484513 // An agent run hands the whole thing over to the protocol and never comes
@@ -501,6 +530,9 @@ func run() error {
501530 Log: log,
502531 Base: base,
503532 Tools: toolsIn,
533+
534+ Rules: rulesFor(ccOpts, root, cc),
535+ Instructions: instructions,
504536 })
505537 }
506538
@@ -681,6 +713,7 @@ func run() error {
681713 // to come up in the tab whose agent is asking.
682714 pol := approval.New(nil)
683715 pol.YOLO = acceptAll
716+ pol.Rules = cc.Rules
684717
685718 var tabSup *subagent.Supervisor
686719 if !*noAgents {
@@ -851,6 +884,12 @@ func run() error {
851884 for _, a := range allowed {
852885 fmt.Println(" " + a)
853886 }
887+ if rules := policy.RuleLines(); len(rules) > 0 {
888+ fmt.Println("from Claude Code's settings:")
889+ for _, r := range rules {
890+ fmt.Println(" " + r)
891+ }
892+ }
854893 continue
855894 }
856895 if err := once(ctx, a, render, sessionDir, session, root, client, line); err != nil {
+8−0internal/agent/loop.goView File
@@ -321,6 +321,13 @@ type Options struct {
321321 NoCache bool
322322 // Stream uses the SSE endpoint so output appears as it is produced.
323323 Stream bool
324+ // Instructions is the project's standing instructions — the CLAUDE.md
325+ // files, already formatted as a section of the system prompt — appended
326+ // after Codey's own. Read once at startup by whoever builds the agent, so
327+ // it is constant for the session like everything else in the prefix.
328+ // Empty leaves the prompt as it was: the model is still told to read a
329+ // CLAUDE.md or AGENTS.md for itself.
330+ Instructions string
324331 // Skills are the instruction sets discovered at startup. Their names and
325332 // descriptions are appended to the system prompt; the bodies are loaded on
326333 // demand by the skill tool.
@@ -553,6 +560,7 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log
553560 if opts.System == "" {
554561 opts.System = defaultSystem
555562 }
563+ opts.System += opts.Instructions
556564 opts.System += skillListing(opts.Skills)
557565 opts.System += modelListing(opts.Models)
558566 return &Agent{client: client, tools: reg, state: state, log: log, opts: opts}
+17−0internal/agent/loop_test.goView File
@@ -815,6 +815,23 @@ func TestSystemPromptListsSkills(t *testing.T) {
815815 }
816816 }
817817
818+ // The project's instructions follow Codey's own and come before the skill
819+ // listing, so the defaults they outrank are the ones already read.
820+ func TestSystemPromptCarriesInstructions(t *testing.T) {
821+ const section = "\n\n# Instructions from CLAUDE.md\n\nRun make test."
822+ a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{
823+ Instructions: section,
824+ Skills: []skills.Skill{{Name: "s", Description: "d"}},
825+ })
826+ sys := a.opts.System
827+ if !strings.HasPrefix(sys, defaultSystem+section) {
828+ t.Errorf("instructions not appended straight after the default prompt:\n%s", sys)
829+ }
830+ if !strings.Contains(sys, "s: d") {
831+ t.Error("the skill listing was lost")
832+ }
833+ }
834+
818835 func TestSystemPromptUnchangedWithoutSkills(t *testing.T) {
819836 a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{})
820837 if a.opts.System != defaultSystem {
+35−2internal/approval/policy.goView File
@@ -36,6 +36,11 @@ type Request struct {
3636 // agent. Several agents share one prompt box, so a request that does not
3737 // say who is asking cannot be decided on.
3838 Agent string
39+ // Forced names the standing rule that insisted on this prompt — an ask
40+ // rule in a settings file — and is empty for an ordinary one. A front end
41+ // that settles some prompts by itself, as an ACP permission floor does,
42+ // must not settle these: the rule exists to stop exactly that.
43+ Forced string
3944 }
4045
4146 // Prompt asks a human. Returning an error aborts the turn.
@@ -90,6 +95,13 @@ type Policy struct {
9095 // fired around the prompt rather than around Approve, so a call allowed
9196 // outright or remembered from earlier says nothing.
9297 Hooks *hooks.Hooks
98+
99+ // Rules are standing rules from a settings file, consulted before
100+ // anything else: before YOLO, before the read-only list and before the
101+ // session's memory. A deny there holds in every mode, which is what makes
102+ // it worth writing down; an ask there forces a prompt the mode would have
103+ // skipped. Nil means none.
104+ Rules RuleSet
93105 }
94106
95107 // New builds a policy.
@@ -108,14 +120,35 @@ func (p *Policy) Approve(ctx context.Context, tool string, input json.RawMessage
108120 // remembered for the whole session, not per agent: "always allow bash" is a
109121 // statement about this working tree, and an agent id is not a trust boundary.
110122 func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.RawMessage) error {
111- if p == nil || p.YOLO || readOnly[tool] {
123+ if p == nil {
124+ return nil
125+ }
126+ // Standing rules first. A deny is the one thing even -yolo does not
127+ // override: somebody wrote it down so that it would hold whatever mode the
128+ // session happened to be in. A read-only tool is not exempt either — a
129+ // rule against reading .env is about reading.
130+ ruling := p.check(tool, input)
131+ switch ruling.Verdict {
132+ case VerdictDeny:
133+ return fmt.Errorf("%s was not run: it is denied by the rule %s. Do not retry it; find another way, or tell the user what you need", tool, ruling.because())
134+ case VerdictAllow:
135+ return nil
136+ }
137+ forced := ruling.Verdict == VerdictAsk
138+ if !forced && (p.YOLO || readOnly[tool]) {
112139 return nil
113140 }
114141 req := Describe(tool, input)
115142 req.Agent = agent
143+ if forced {
144+ req.Forced = ruling.because()
145+ req.Summary += " — asked because of " + req.Forced
146+ }
116147
148+ // An ask rule outranks the session's memory too: "always this tool" given
149+ // to an earlier prompt was not an answer to a rule that says always ask.
117150 p.mu.Lock()
118- allowed := p.tools[tool] || (req.Rememberable && p.commands[memoryKey(tool, req.Detail)])
151+ allowed := !forced && (p.tools[tool] || (req.Rememberable && p.commands[memoryKey(tool, req.Detail)]))
119152 p.mu.Unlock()
120153 if allowed {
121154 return nil
+73−0internal/approval/ruleset.goaddedView File
@@ -0,0 +1,73 @@
1+ package approval
2+
3+ import "encoding/json"
4+
5+ // Verdict is what a set of standing rules says about one tool call, before
6+ // anyone is asked and before anything the session remembers is consulted.
7+ type Verdict int
8+
9+ const (
10+ // NoVerdict means no rule spoke, and the call is decided the ordinary way:
11+ // the mode, the session's memory, and then a prompt.
12+ NoVerdict Verdict = iota
13+ // VerdictAllow lets the call through without a prompt.
14+ VerdictAllow
15+ // VerdictAsk puts the call to a human even when the mode would have let
16+ // it through, and even when the session remembers allowing it.
17+ VerdictAsk
18+ // VerdictDeny refuses the call outright, in every mode, -yolo included.
19+ VerdictDeny
20+ )
21+
22+ // Ruling is a verdict and the rule that produced it, for the message the model
23+ // reads and the prompt a human reads. "Denied" with no reason is a dead end;
24+ // "denied by Bash(rm:*) in ~/.claude/settings.json" is something to fix.
25+ type Ruling struct {
26+ Verdict Verdict
27+ // Rule is the rule as it was written, e.g. Bash(npm run test:*).
28+ Rule string
29+ // Source is where the rule was written, e.g. ~/.claude/settings.json.
30+ Source string
31+ }
32+
33+ // RuleSet is a set of standing rules that act before the session's own
34+ // memory: rules written down in a settings file rather than given as an
35+ // answer to a prompt. Claude Code's permissions.allow, deny and ask are the
36+ // ones that exist so far.
37+ //
38+ // It is an interface so this package stays ignorant of where rules come from
39+ // and of the grammar they are written in. Whoever loads them decides which
40+ // files are honoured; the policy only acts on what they say.
41+ type RuleSet interface {
42+ // Check rules on one call. Deny outranks ask, which outranks allow, so a
43+ // single rule set can never be argued out of a refusal by a broader
44+ // permission elsewhere in it.
45+ Check(tool string, input json.RawMessage) Ruling
46+ // Lines describe the rules for /allowed, grouped by where they came from.
47+ Lines() []string
48+ }
49+
50+ // check is Rules.Check, tolerating a policy with no rules.
51+ func (p *Policy) check(tool string, input json.RawMessage) Ruling {
52+ if p.Rules == nil {
53+ return Ruling{}
54+ }
55+ return p.Rules.Check(tool, input)
56+ }
57+
58+ // RuleLines is what /allowed shows under the session's own memory: the
59+ // standing rules, and the files they came from. Nil when there are none.
60+ func (p *Policy) RuleLines() []string {
61+ if p == nil || p.Rules == nil {
62+ return nil
63+ }
64+ return p.Rules.Lines()
65+ }
66+
67+ // because names the rule behind a ruling, for a message.
68+ func (r Ruling) because() string {
69+ if r.Source == "" {
70+ return r.Rule
71+ }
72+ return r.Rule + " in " + r.Source
73+ }
+143−0internal/approval/ruleset_test.goaddedView File
@@ -0,0 +1,143 @@
1+ package approval
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "reflect"
7+ "strings"
8+ "testing"
9+ )
10+
11+ // fixedRules rules the same way on every call to one tool.
12+ type fixedRules struct {
13+ tool string
14+ verdict Verdict
15+ }
16+
17+ func (f fixedRules) Check(tool string, _ json.RawMessage) Ruling {
18+ if tool != f.tool {
19+ return Ruling{}
20+ }
21+ return Ruling{Verdict: f.verdict, Rule: "Rule(x)", Source: "settings.json"}
22+ }
23+
24+ func (f fixedRules) Lines() []string { return []string{"settings.json", " rule"} }
25+
26+ func TestStandingRules(t *testing.T) {
27+ tests := []struct {
28+ name string
29+ tool string
30+ verdict Verdict
31+ yolo bool
32+ remembered bool
33+ wantErr bool
34+ wantPrompt int
35+ }{
36+ {name: "deny holds under yolo", tool: "bash", verdict: VerdictDeny, yolo: true, wantErr: true},
37+ {name: "deny holds for a read-only tool", tool: "read_file", verdict: VerdictDeny, wantErr: true},
38+ {name: "deny beats session memory", tool: "bash", verdict: VerdictDeny, remembered: true, wantErr: true},
39+ {name: "allow skips the prompt", tool: "bash", verdict: VerdictAllow},
40+ {name: "ask prompts under yolo", tool: "bash", verdict: VerdictAsk, yolo: true, wantPrompt: 1},
41+ {name: "ask prompts for a read-only tool", tool: "read_file", verdict: VerdictAsk, wantPrompt: 1},
42+ {name: "ask beats session memory", tool: "bash", verdict: VerdictAsk, remembered: true, wantPrompt: 1},
43+ {name: "no verdict prompts as usual", tool: "bash", verdict: NoVerdict, wantPrompt: 1},
44+ {name: "no verdict still honours memory", tool: "bash", verdict: NoVerdict, remembered: true},
45+ }
46+ for _, tc := range tests {
47+ t.Run(tc.name, func(t *testing.T) {
48+ var asked int
49+ var forced string
50+ p := New(func(_ context.Context, r Request) (Decision, error) {
51+ asked++
52+ forced = r.Forced
53+ return Once, nil
54+ })
55+ p.YOLO = tc.yolo
56+ p.Rules = fixedRules{tool: tc.tool, verdict: tc.verdict}
57+ if tc.remembered {
58+ p.tools[tc.tool] = true
59+ }
60+ err := approve(t, p, tc.tool, `{"command":"ls","path":"a"}`)
61+ if (err != nil) != tc.wantErr {
62+ t.Fatalf("err = %v, want error %v", err, tc.wantErr)
63+ }
64+ if err != nil && !strings.Contains(err.Error(), "Rule(x) in settings.json") {
65+ t.Errorf("denial %q should name the rule and its file", err)
66+ }
67+ if asked != tc.wantPrompt {
68+ t.Errorf("prompted %d times, want %d", asked, tc.wantPrompt)
69+ }
70+ if tc.verdict == VerdictAsk && forced == "" {
71+ t.Error("a prompt an ask rule forced should say so")
72+ }
73+ })
74+ }
75+ }
76+
77+ // Headless, an ask rule is a refusal with nobody to put the question to —
78+ // which is what the rule asked for.
79+ func TestAskRuleHeadlessDenies(t *testing.T) {
80+ p := New(nil)
81+ p.YOLO = true
82+ p.Rules = fixedRules{tool: "bash", verdict: VerdictAsk}
83+ if err := approve(t, p, "bash", `{"command":"ls"}`); err == nil {
84+ t.Error("an ask rule with no prompt should not run the call")
85+ }
86+ }
87+
88+ func TestRuleLines(t *testing.T) {
89+ var p *Policy
90+ if p.RuleLines() != nil {
91+ t.Error("nil policy")
92+ }
93+ p = New(nil)
94+ if p.RuleLines() != nil {
95+ t.Error("no rules")
96+ }
97+ p.Rules = fixedRules{}
98+ if len(p.RuleLines()) != 2 {
99+ t.Error("lines not passed through")
100+ }
101+ }
102+
103+ func TestSimpleCommands(t *testing.T) {
104+ tests := []struct {
105+ script string
106+ want []string
107+ pinned []bool
108+ opaque bool
109+ }{
110+ {"git status", []string{"git status"}, []bool{true}, false},
111+ {"go build ./... && go test ./...", []string{"go build ./...", "go test ./..."}, []bool{true, true}, false},
112+ {"ls | grep x; echo done", []string{"ls", "grep x", "echo done"}, []bool{true, true, true}, false},
113+ {"(cd sub && make)", []string{"cd sub", "make"}, []bool{true, true}, false},
114+ {`git commit -m "fix: the thing"`, []string{"git commit -m fix: the thing"}, []bool{true}, false},
115+ {"rm $FILE", []string{"rm <expanded>"}, []bool{true}, false},
116+ {"ls *.go", []string{"ls <expanded>"}, []bool{true}, false},
117+ {"FOO=1 make", []string{"make"}, []bool{false}, false},
118+ {"echo x > out.txt", []string{"echo x"}, []bool{false}, false},
119+ {"echo x >> out.txt", []string{"echo x"}, []bool{false}, false},
120+ {"echo x 2>&1 >/dev/null", []string{"echo x"}, []bool{true}, false},
121+ {"export A=1", []string{"export"}, []bool{false}, false},
122+ {"echo $(whoami)", []string{"echo <expanded>", "whoami"}, []bool{true, true}, true},
123+ {"diff <(ls a) <(ls b)", []string{"diff <expanded> <expanded>", "ls a", "ls b"}, []bool{true, true, true}, true},
124+ }
125+ for _, tc := range tests {
126+ cmds, info := SimpleCommands(tc.script)
127+ var got []string
128+ var pinned []bool
129+ for _, c := range cmds {
130+ got = append(got, c.Text())
131+ pinned = append(pinned, c.Pinned)
132+ }
133+ if !reflect.DeepEqual(got, tc.want) || !reflect.DeepEqual(pinned, tc.pinned) {
134+ t.Errorf("%q: got %q %v, want %q %v", tc.script, got, pinned, tc.want, tc.pinned)
135+ }
136+ if info.Opaque != tc.opaque {
137+ t.Errorf("%q: opaque %v, want %v", tc.script, info.Opaque, tc.opaque)
138+ }
139+ }
140+ if cmds, info := SimpleCommands("echo 'open"); cmds != nil || info.ParseError == nil {
141+ t.Error("a parse error should yield nothing")
142+ }
143+ }
+158−0internal/approval/simple.goaddedView File
@@ -0,0 +1,158 @@
1+ package approval
2+
3+ import (
4+ "strings"
5+
6+ "mvdan.cc/sh/v3/syntax"
7+ )
8+
9+ // SimpleCommand is one command a shell script runs, as its words.
10+ //
11+ // It exists for rules written as command prefixes — Claude Code's
12+ // Bash(npm run test:*) — which can only be matched honestly one simple command
13+ // at a time. Matched against the whole line, "npm run test && rm -rf ~" starts
14+ // with "npm run test", and an allow rule written for the first half would
15+ // quietly allow the second.
16+ type SimpleCommand struct {
17+ // Words are the command's words after quote removal: "git", "commit",
18+ // "-m", "fix it". A word the shell would build at run time — a variable,
19+ // a glob, a backslash escape this reader does not undo — is left empty,
20+ // and Literal says so.
21+ Words []string
22+ // Literal is parallel to Words: true where the word is fixed text.
23+ Literal []bool
24+ // Pinned is false when something about this command is decided by more
25+ // than its words: a variable assignment in front of it, which can change
26+ // what the name resolves to (PATH=. git), or an output redirection to a
27+ // file, which writes somewhere no prefix of the words mentions.
28+ Pinned bool
29+ }
30+
31+ // Text is the command as words joined by single spaces, with a placeholder
32+ // where a word is not fixed. It is for messages, not for matching.
33+ func (c SimpleCommand) Text() string {
34+ out := make([]string, len(c.Words))
35+ for i, w := range c.Words {
36+ if !c.Literal[i] {
37+ w = "<expanded>"
38+ }
39+ out[i] = w
40+ }
41+ return strings.Join(out, " ")
42+ }
43+
44+ // SimpleCommands splits a script into every simple command it contains,
45+ // however they are joined: ;, &&, ||, pipes, subshells, the bodies of loops,
46+ // conditionals and functions. The ShellInfo is Analyze's for the same script;
47+ // when it is opaque, the list is what could be seen and not necessarily
48+ // everything that will run, so a caller deciding to allow something must
49+ // treat an opaque script as matching nothing.
50+ func SimpleCommands(script string) ([]SimpleCommand, ShellInfo) {
51+ info := Analyze(script)
52+ if info.ParseError != nil {
53+ return nil, info
54+ }
55+ f, err := syntax.NewParser().Parse(strings.NewReader(script), "")
56+ if err != nil {
57+ return nil, info
58+ }
59+ var out []SimpleCommand
60+ syntax.Walk(f, func(node syntax.Node) bool {
61+ switch n := node.(type) {
62+ case *syntax.ProcSubst:
63+ // <(cmd) runs cmd as surely as $(cmd) does, and Analyze does not
64+ // look for it, so it is marked here.
65+ info.Opaque, info.Reason = true, "it contains process substitution, so what runs is decided at run time"
66+ case *syntax.Stmt:
67+ call, ok := n.Cmd.(*syntax.CallExpr)
68+ if ok && len(call.Args) > 0 {
69+ out = append(out, simple(call, n.Redirs))
70+ return true
71+ }
72+ if decl, ok := n.Cmd.(*syntax.DeclClause); ok {
73+ // export, local, declare: a builtin with assignments for
74+ // arguments. Kept as a command so a rule must account for it,
75+ // never pinned, because an assignment is what it does.
76+ out = append(out, SimpleCommand{Words: []string{decl.Variant.Value}, Literal: []bool{true}})
77+ }
78+ }
79+ return true
80+ })
81+ return out, info
82+ }
83+
84+ func simple(call *syntax.CallExpr, redirs []*syntax.Redirect) SimpleCommand {
85+ c := SimpleCommand{Pinned: len(call.Assigns) == 0}
86+ for _, w := range call.Args {
87+ text, ok := unquote(w)
88+ c.Words = append(c.Words, text)
89+ c.Literal = append(c.Literal, ok)
90+ }
91+ for _, r := range redirs {
92+ if writesFile(r) {
93+ c.Pinned = false
94+ }
95+ }
96+ return c
97+ }
98+
99+ // unquote returns a word's text after quote removal when the text is fixed,
100+ // and false when any part of it is expanded at run time.
101+ func unquote(w *syntax.Word) (string, bool) {
102+ var b strings.Builder
103+ for _, part := range w.Parts {
104+ switch p := part.(type) {
105+ case *syntax.Lit:
106+ // A backslash in an unquoted literal is an escape the shell will
107+ // remove; rather than reimplement that, the word is not fixed.
108+ // Globs and a leading ~ are expanded by the shell, so they are
109+ // not fixed either.
110+ if strings.ContainsAny(p.Value, "\\*?[") || (b.Len() == 0 && strings.HasPrefix(p.Value, "~")) {
111+ return "", false
112+ }
113+ b.WriteString(p.Value)
114+ case *syntax.SglQuoted:
115+ if p.Dollar {
116+ return "", false
117+ }
118+ b.WriteString(p.Value)
119+ case *syntax.DblQuoted:
120+ for _, inner := range p.Parts {
121+ lit, ok := inner.(*syntax.Lit)
122+ if !ok || strings.Contains(lit.Value, "\\") {
123+ return "", false
124+ }
125+ b.WriteString(lit.Value)
126+ }
127+ default:
128+ return "", false
129+ }
130+ }
131+ return b.String(), true
132+ }
133+
134+ // writesFile reports whether a redirection writes to a file. Duplicating a
135+ // descriptor (2>&1) and throwing output away (>/dev/null) do not.
136+ func writesFile(r *syntax.Redirect) bool {
137+ switch r.Op {
138+ case syntax.RdrIn, syntax.Hdoc, syntax.DashHdoc, syntax.WordHdoc, syntax.DplIn:
139+ return false
140+ case syntax.DplOut:
141+ target, ok := unquote(r.Word)
142+ return !ok || !(target == "-" || isDigits(target))
143+ }
144+ target, ok := unquote(r.Word)
145+ return !ok || target != "/dev/null"
146+ }
147+
148+ func isDigits(s string) bool {
149+ if s == "" {
150+ return false
151+ }
152+ for _, r := range s {
153+ if r < '0' || r > '9' {
154+ return false
155+ }
156+ }
157+ return true
158+ }
+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+ }
+247−0internal/ccsettings/instructions.goaddedView File
@@ -0,0 +1,247 @@
1+ package ccsettings
2+
3+ import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "regexp"
8+ "strings"
9+ )
10+
11+ // Instruction is one CLAUDE.md-style file, read.
12+ type Instruction struct {
13+ Path string
14+ // Shown names the file in the prompt, and Role says whose it is.
15+ Shown string
16+ Role string
17+ Body string
18+ }
19+
20+ // Roles, as the prompt describes them. Saying whose a file is matters to the
21+ // model as much as to a person: the user's own preferences and a repository's
22+ // conventions are different kinds of instruction.
23+ const (
24+ roleUser = "the user's own instructions, for every project"
25+ roleProject = "this project's instructions, checked in"
26+ roleLocal = "the user's private instructions for this project"
27+ importMaxHop = 5
28+ // maxInstructionBytes bounds one file. These go into every request, and
29+ // a generated file or a stray log someone named CLAUDE.md should not be
30+ // able to fill the window.
31+ maxInstructionBytes = 64 << 10
32+ )
33+
34+ // LoadInstructions reads the files Claude Code loads into a conversation
35+ // started in root, in the order it loads them, least specific first:
36+ //
37+ // - ~/.claude/CLAUDE.md, the user's own;
38+ // - then for each directory from the top of the project down to root —
39+ // the top being the nearest directory with a .git in it, or the home
40+ // directory if there is none on the way — CLAUDE.md, .claude/CLAUDE.md
41+ // and CLAUDE.local.md.
42+ //
43+ // Each file's @path imports follow it, up to five hops deep. An import is
44+ // relative to the file it appears in, and may not leave the tree its file
45+ // belongs to: a project's files may import from the project, the user's from
46+ // the home directory. A repository's CLAUDE.md that could import
47+ // @~/.aws/credentials would be a repository reading your credentials into
48+ // the conversation, which is then sent to the model.
49+ //
50+ // Claude Code also loads a subdirectory's CLAUDE.md when it first reads a
51+ // file there, and .claude/rules/; neither is done here.
52+ func LoadInstructions(home, root string) ([]Instruction, []error) {
53+ l := &instructionLoader{home: home, root: root, seen: map[string]bool{}}
54+ if home != "" {
55+ l.load(filepath.Join(home, UserInstructions), roleUser, home, "", 0)
56+ }
57+ if root == "" {
58+ return l.out, l.errs
59+ }
60+ dirs := projectDirs(home, root)
61+ top := dirs[0]
62+ for _, dir := range dirs {
63+ for _, name := range []string{instructionsName, filepath.Join(".claude", instructionsName), localInstructions} {
64+ path := filepath.Join(dir, name)
65+ role, bound := roleProject, top
66+ if name == localInstructions {
67+ role = roleLocal
68+ // A CLAUDE.local.md is the user's, and may reach into their
69+ // home directory — Claude Code's own docs suggest importing a
70+ // personal file from there — unless git is tracking it, which
71+ // makes it the repository's after all.
72+ if _, err := os.Lstat(path); err != nil {
73+ continue
74+ }
75+ if home != "" && !gitTracks(dir, localInstructions) {
76+ bound = home
77+ }
78+ }
79+ l.load(path, role, bound, "", 0)
80+ }
81+ }
82+ return l.out, l.errs
83+ }
84+
85+ // projectDirs is root and the directories above it that belong to the same
86+ // project, outermost first. The walk stops at a directory holding .git, or at
87+ // the home directory. A root that is neither in a repository nor under home
88+ // is a project of one directory: walking up to / would read whatever CLAUDE.md
89+ // happened to be lying in /tmp.
90+ func projectDirs(home, root string) []string {
91+ root = filepath.Clean(root)
92+ var dirs []string
93+ for d := root; ; {
94+ dirs = append(dirs, d)
95+ if _, err := os.Lstat(filepath.Join(d, ".git")); err == nil {
96+ break
97+ }
98+ if home != "" && d == filepath.Clean(home) {
99+ break
100+ }
101+ parent := filepath.Dir(d)
102+ if parent == d {
103+ return []string{root}
104+ }
105+ d = parent
106+ }
107+ for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 {
108+ dirs[i], dirs[j] = dirs[j], dirs[i]
109+ }
110+ return dirs
111+ }
112+
113+ type instructionLoader struct {
114+ home string
115+ root string
116+ seen map[string]bool
117+ out []Instruction
118+ errs []error
119+ }
120+
121+ // load reads one file and then its imports. bound is the directory the file
122+ // and everything it imports must stay inside; importer is the file that
123+ // imported this one, empty for a file found by name.
124+ func (l *instructionLoader) load(path, role, bound, importer string, hop int) {
125+ path = filepath.Clean(path)
126+ real, err := filepath.EvalSymlinks(path)
127+ if err != nil {
128+ return // not there, which is the usual case
129+ }
130+ if l.seen[real] {
131+ return
132+ }
133+ // The user's own file is wherever they keep it — often a symlink into a
134+ // dotfiles repository. Everything else, including what it imports, stays
135+ // inside its bound, and that is checked on the resolved path: a symlink
136+ // committed to a repository can point anywhere.
137+ ownFile := importer == "" && role == roleUser
138+ if !ownFile && !within(bound, real) {
139+ if importer != "" {
140+ where := "the project"
141+ if bound == l.home {
142+ where = "the home directory"
143+ }
144+ l.errs = append(l.errs, fmt.Errorf("CLAUDE.md: %s imports %s, which is outside %s — skipped", l.shown(importer), l.shown(path), where))
145+ }
146+ return
147+ }
148+ info, err := os.Stat(real)
149+ if err != nil || !info.Mode().IsRegular() {
150+ return
151+ }
152+ raw, err := os.ReadFile(real)
153+ if err != nil {
154+ l.errs = append(l.errs, fmt.Errorf("CLAUDE.md: %w", err))
155+ return
156+ }
157+ l.seen[real] = true
158+ body := string(raw)
159+ if len(body) > maxInstructionBytes {
160+ l.errs = append(l.errs, fmt.Errorf("CLAUDE.md: %s is over %d KB — only the start of it is used", l.shown(path), maxInstructionBytes>>10))
161+ body = body[:maxInstructionBytes]
162+ }
163+ in := Instruction{Path: path, Shown: l.shown(path), Role: role, Body: strings.TrimSpace(body)}
164+ if importer != "" {
165+ in.Role = "imported by " + l.shown(importer)
166+ }
167+ if in.Body != "" {
168+ l.out = append(l.out, in)
169+ }
170+ if hop >= importMaxHop {
171+ return
172+ }
173+ for _, ref := range imports(body) {
174+ target := ref
175+ switch {
176+ case strings.HasPrefix(ref, "~/"):
177+ if l.home == "" {
178+ continue
179+ }
180+ target = filepath.Join(l.home, ref[2:])
181+ case !filepath.IsAbs(ref):
182+ target = filepath.Join(filepath.Dir(path), ref)
183+ }
184+ l.load(target, role, bound, path, hop+1)
185+ }
186+ }
187+
188+ func (l *instructionLoader) shown(path string) string {
189+ return shown(l.home, l.root, path)
190+ }
191+
192+ func within(dir, path string) bool {
193+ if real, err := filepath.EvalSymlinks(dir); err == nil {
194+ dir = real
195+ }
196+ rel, err := filepath.Rel(dir, path)
197+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
198+ }
199+
200+ var (
201+ importRef = regexp.MustCompile("(?:^|[\\s(])@([^\\s`]+)")
202+ inlineCode = regexp.MustCompile("`[^`]*`")
203+ )
204+
205+ // imports finds the @path references in a file, skipping code: an @ in a
206+ // code block or span is an example, or a decorator, not an import.
207+ func imports(body string) []string {
208+ var out []string
209+ fenced := false
210+ for _, line := range strings.Split(body, "\n") {
211+ if strings.HasPrefix(strings.TrimSpace(line), "```") {
212+ fenced = !fenced
213+ continue
214+ }
215+ if fenced {
216+ continue
217+ }
218+ line = inlineCode.ReplaceAllString(line, "")
219+ for _, m := range importRef.FindAllStringSubmatch(line, -1) {
220+ ref := strings.TrimRight(m[1], ".,;:!?)]'\"")
221+ if ref != "" {
222+ out = append(out, ref)
223+ }
224+ }
225+ }
226+ return out
227+ }
228+
229+ // FormatInstructions is the system prompt section the files make. It is built
230+ // once, at startup, and so is constant for the session: the cached prefix
231+ // still matches request to request.
232+ func FormatInstructions(files []Instruction) string {
233+ if len(files) == 0 {
234+ return ""
235+ }
236+ var b strings.Builder
237+ b.WriteString("\n\n# Instructions from CLAUDE.md\n\n" +
238+ "These are the instruction files Claude Code would load for this repository,\n" +
239+ "read at startup and given here in full, so there is no need to read them\n" +
240+ "again. Follow them: on anything about this codebase they outrank the\n" +
241+ "defaults above. Later files are more specific to where you are working, and\n" +
242+ "win where two disagree.")
243+ for _, f := range files {
244+ fmt.Fprintf(&b, "\n\n## %s (%s)\n\n%s", f.Shown, f.Role, f.Body)
245+ }
246+ return b.String()
247+ }
+170−0internal/ccsettings/instructions_test.goaddedView File
@@ -0,0 +1,170 @@
1+ package ccsettings
2+
3+ import (
4+ "os"
5+ "path/filepath"
6+ "reflect"
7+ "strings"
8+ "testing"
9+ )
10+
11+ func shownOf(files []Instruction) []string {
12+ var out []string
13+ for _, f := range files {
14+ out = append(out, f.Shown)
15+ }
16+ return out
17+ }
18+
19+ func TestLoadInstructionsOrder(t *testing.T) {
20+ home, _ := dirs(t)
21+ repo := filepath.Join(home, "src", "repo")
22+ root := filepath.Join(repo, "services", "api")
23+ os.MkdirAll(filepath.Join(repo, ".git"), 0o755)
24+ os.MkdirAll(root, 0o755)
25+
26+ write(t, home, UserInstructions, "user rules")
27+ // Above the git root: not part of this project.
28+ write(t, home, "src/CLAUDE.md", "outside the repository")
29+ write(t, repo, "CLAUDE.md", "repo rules")
30+ write(t, repo, ".claude/CLAUDE.md", "repo dot-claude rules")
31+ write(t, filepath.Join(repo, "services"), "CLAUDE.md", "services rules")
32+ write(t, root, "CLAUDE.md", "api rules")
33+ write(t, root, "CLAUDE.local.md", "my api notes")
34+ write(t, root, "AGENTS.md", "not Claude's file")
35+
36+ files, errs := LoadInstructions(home, root)
37+ if len(errs) != 0 {
38+ t.Fatalf("errors: %v", errs)
39+ }
40+ got := shownOf(files)
41+ want := []string{
42+ "~/.claude/CLAUDE.md",
43+ "~/src/repo/CLAUDE.md",
44+ "~/src/repo/.claude/CLAUDE.md",
45+ "~/src/repo/services/CLAUDE.md",
46+ "CLAUDE.md",
47+ "CLAUDE.local.md",
48+ }
49+ if !reflect.DeepEqual(got, want) {
50+ t.Errorf("got %q\nwant %q", got, want)
51+ }
52+ if files[0].Role != roleUser || files[5].Role != roleLocal || files[4].Role != roleProject {
53+ t.Errorf("roles: %q %q %q", files[0].Role, files[4].Role, files[5].Role)
54+ }
55+ }
56+
57+ // With no repository on the way up, the walk stops at the home directory
58+ // rather than reading whatever lies above it.
59+ func TestLoadInstructionsNoRepository(t *testing.T) {
60+ home, root := dirs(t)
61+ write(t, home, "CLAUDE.md", "home-level")
62+ write(t, filepath.Dir(home), "CLAUDE.md", "above home")
63+ write(t, root, "CLAUDE.md", "here")
64+ files, _ := LoadInstructions(home, root)
65+ got := shownOf(files)
66+ want := []string{"~/CLAUDE.md", "CLAUDE.md"}
67+ if !reflect.DeepEqual(got, want) {
68+ t.Errorf("got %q, want %q", got, want)
69+ }
70+
71+ // Outside home altogether, only the root itself.
72+ elsewhere := filepath.Join(filepath.Dir(home), "tmp", "x")
73+ os.MkdirAll(elsewhere, 0o755)
74+ write(t, elsewhere, "CLAUDE.md", "x")
75+ files, _ = LoadInstructions(home, elsewhere)
76+ if len(files) != 1 || files[0].Body != "x" {
77+ t.Errorf("got %+v", files)
78+ }
79+ }
80+
81+ func TestInstructionImports(t *testing.T) {
82+ home, root := dirs(t)
83+ os.MkdirAll(filepath.Join(root, ".git"), 0o755)
84+ write(t, root, "CLAUDE.md", strings.Join([]string{
85+ "See @AGENTS.md for the rules, and @docs/style.md.",
86+ "Mail me at someone@example.com, which is not an import.",
87+ "`@inline` is code, and so is this:",
88+ "```",
89+ "@decorator",
90+ "```",
91+ "@~/.ssh/id_rsa is outside the project.",
92+ "@../escape.md is too.",
93+ "@missing.md is not there.",
94+ }, "\n"))
95+ write(t, root, "AGENTS.md", "agents body, see @CLAUDE.md")
96+ write(t, root, "docs/style.md", "style body @deeper/one.md")
97+ write(t, root, "docs/deeper/one.md", "one @two.md")
98+ write(t, root, "docs/deeper/two.md", "two @three.md")
99+ write(t, root, "docs/deeper/three.md", "three @four.md")
100+ write(t, root, "docs/deeper/four.md", "four @five.md")
101+ write(t, root, "docs/deeper/five.md", "five — too deep")
102+ write(t, root, "inline", "should not load")
103+ write(t, root, "decorator", "should not load")
104+ write(t, home, ".ssh/id_rsa", "PRIVATE KEY")
105+ write(t, home, "src/escape.md", "escaped")
106+
107+ files, errs := LoadInstructions(home, root)
108+ got := shownOf(files)
109+ want := []string{
110+ "CLAUDE.md",
111+ "AGENTS.md",
112+ "docs/style.md",
113+ "docs/deeper/one.md",
114+ "docs/deeper/two.md",
115+ "docs/deeper/three.md",
116+ "docs/deeper/four.md",
117+ }
118+ if !reflect.DeepEqual(got, want) {
119+ t.Errorf("got %q\nwant %q", got, want)
120+ }
121+ for _, f := range files {
122+ if strings.Contains(f.Body, "PRIVATE KEY") || f.Body == "escaped" {
123+ t.Errorf("a project file imported %s from outside the project", f.Path)
124+ }
125+ }
126+ if files[1].Role != "imported by CLAUDE.md" {
127+ t.Errorf("role %q", files[1].Role)
128+ }
129+ if len(errs) != 2 {
130+ t.Errorf("want the two escapes reported, got %v", errs)
131+ }
132+ }
133+
134+ // The user's own file may import from the home directory: it is theirs.
135+ func TestUserInstructionsImportFromHome(t *testing.T) {
136+ home, root := dirs(t)
137+ write(t, home, UserInstructions, "@~/notes/prefs.md")
138+ write(t, home, "notes/prefs.md", "tabs, not spaces")
139+ files, _ := LoadInstructions(home, root)
140+ if len(files) != 2 || files[1].Body != "tabs, not spaces" {
141+ t.Errorf("got %+v", files)
142+ }
143+ }
144+
145+ // A symlink committed to a repository can point anywhere, so the project's
146+ // own files are checked where they lead.
147+ func TestProjectInstructionSymlinkOut(t *testing.T) {
148+ home, root := dirs(t)
149+ os.MkdirAll(filepath.Join(root, ".git"), 0o755)
150+ secret := write(t, home, ".aws/credentials", "KEY")
151+ if err := os.Symlink(secret, filepath.Join(root, "CLAUDE.md")); err != nil {
152+ t.Skip(err)
153+ }
154+ files, _ := LoadInstructions(home, root)
155+ if len(files) != 0 {
156+ t.Errorf("followed a symlink out of the project: %+v", files)
157+ }
158+ }
159+
160+ func TestFormatInstructions(t *testing.T) {
161+ if FormatInstructions(nil) != "" {
162+ t.Error("no files should add nothing to the prompt")
163+ }
164+ got := FormatInstructions([]Instruction{{Shown: "CLAUDE.md", Role: roleProject, Body: "Run make test."}})
165+ for _, want := range []string{"# Instructions from CLAUDE.md", "## CLAUDE.md (this project's instructions, checked in)", "Run make test."} {
166+ if !strings.Contains(got, want) {
167+ t.Errorf("missing %q in:\n%s", want, got)
168+ }
169+ }
170+ }
+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

Large diffs are not rendered by default.

+300−0internal/ccsettings/ruleset_test.goaddedView File

Large diffs are not rendered by default.

+194−0internal/ccsettings/trust.goaddedView File

Large diffs are not rendered by default.

+114−0internal/ccsettings/trust_test.goaddedView File

Large diffs are not rendered by default.

+23−0internal/serve/floor_test.goaddedView File

Large diffs are not rendered by default.

+5−0internal/serve/serve.goView File

Large diffs are not rendered by default.

+5−0internal/tui/model.goView File

Large diffs are not rendered by default.