Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> # Conflicts: # README.md
Showing 25 changed files with 3565 additions and 7 deletions.
| @@ -119,7 +119,8 @@ would break your Claude Code login. | ||
| 119 | 119 | Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`, |
| 120 | 120 | `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`, |
| 121 | 121 | `-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`. | |
| 123 | 124 | |
| 124 | 125 | ### Flags and thresholds |
| 125 | 126 | |
| @@ -965,6 +966,7 @@ rather than the project's. | ||
| 965 | 966 | | Path | What | |
| 966 | 967 | | --- | --- | |
| 967 | 968 | | `~/.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 | | |
| 968 | 970 | | `<repo>/.codey/sessions/` | saved conversations, which is what `-resume` and `/resume` read | |
| 969 | 971 | | `<repo>/.codey/events.jsonl` | the event log METRICS.md is built from | |
| 970 | 972 | | `<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 | ||
| 1031 | 1033 | their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20, |
| 1032 | 1034 | and any key skips the one playing. |
| 1033 | 1035 | |
| 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 | + | |
| 1034 | 1144 | ## Tools |
| 1035 | 1145 | |
| 1036 | 1146 | | Tool | Approval | Notes | |
| @@ -1280,7 +1390,85 @@ Only tools are used: not resources, prompts or sampling, and no HTTP | ||
| 1280 | 1390 | transport. Pictures a tool returns are described to the model rather than |
| 1281 | 1391 | shown. A server's directory is fixed when it starts, so after `/worktree` it |
| 1282 | 1392 | 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. | |
| 1284 | 1472 | |
| 1285 | 1473 | ## Hooks |
| 1286 | 1474 | |
| @@ -1533,6 +1721,14 @@ interpreter with `-c` cannot be analysed at all, so it is marked opaque and | ||
| 1533 | 1721 | allowlists for shell commands do not work; this exists to make the prompt |
| 1534 | 1722 | specific, not to make the decision for you. |
| 1535 | 1723 | |
| 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 | + | |
| 1536 | 1732 | Hooks sit outside all three layers, deliberately: a command in `"hooks"` runs |
| 1537 | 1733 | on every event it is attached to, without a prompt, because you put it in your |
| 1538 | 1734 | 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 | ||
| 1541 | 1737 | anything able to write that file has arranged to run commands here. `-no-hooks` |
| 1542 | 1738 | is the switch that says no for one run. MCP servers are the same bargain: each |
| 1543 | 1739 | 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. | |
| 1545 | 1743 | |
| 1546 | 1744 | **A sandbox.** This is the layer that actually contains a mistake, and the |
| 1547 | 1745 | 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 | ||
| 1581 | 1779 | internal/mcp MCP client: starts the configured servers, offers their tools |
| 1582 | 1780 | internal/config saved defaults: model, effort, approvals, web search, theme, diagnostics |
| 1583 | 1781 | 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 | |
| 1584 | 1783 | internal/ask questions the agent puts to you mid-task, and the tool for it |
| 1585 | 1784 | internal/hooks the lifecycle, published: fan-out, and the config's commands |
| 1586 | 1785 | internal/herdr reporting agent state to the herdr pane Codey is running in |
| @@ -25,6 +25,7 @@ import ( | ||
| 25 | 25 | |
| 26 | 26 | "github.com/johannSo/codey/internal/acp" |
| 27 | 27 | "github.com/johannSo/codey/internal/agent" |
| 28 | + "github.com/johannSo/codey/internal/approval" | |
| 28 | 29 | "github.com/johannSo/codey/internal/llm" |
| 29 | 30 | "github.com/johannSo/codey/internal/metrics" |
| 30 | 31 | "github.com/johannSo/codey/internal/serve" |
| @@ -214,6 +215,12 @@ type acpDeps struct { | ||
| 214 | 215 | Base agent.Options |
| 215 | 216 | // Tools is main's factory: a root in, a tool-set builder out. |
| 216 | 217 | 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 | |
| 217 | 224 | } |
| 218 | 225 | |
| 219 | 226 | // 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 { | ||
| 250 | 257 | // the parent's limits. |
| 251 | 258 | newTools := deps.Tools(req.Root) |
| 252 | 259 | 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 | + } | |
| 253 | 268 | |
| 254 | 269 | set := serve.AgentSet{} |
| 255 | 270 | if !deps.NoAgents { |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -17,6 +17,7 @@ import ( | ||
| 17 | 17 | "github.com/johannSo/codey/internal/agent" |
| 18 | 18 | "github.com/johannSo/codey/internal/approval" |
| 19 | 19 | "github.com/johannSo/codey/internal/ask" |
| 20 | + "github.com/johannSo/codey/internal/ccsettings" | |
| 20 | 21 | "github.com/johannSo/codey/internal/claudecode" |
| 21 | 22 | "github.com/johannSo/codey/internal/config" |
| 22 | 23 | "github.com/johannSo/codey/internal/diag" |
| @@ -95,6 +96,7 @@ func run() error { | ||
| 95 | 96 | noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr") |
| 96 | 97 | noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it") |
| 97 | 98 | 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") | |
| 98 | 100 | |
| 99 | 101 | // Read the line before the flag package does. Three invocations belong to |
| 100 | 102 | // a program driving Codey rather than to a person — see agentcmd.go — and |
| @@ -376,9 +378,34 @@ func run() error { | ||
| 376 | 378 | // in and stay there across /worktree, since a server's working directory |
| 377 | 379 | // is fixed when it starts. A server that will not start is reported and |
| 378 | 380 | // 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 | + | |
| 379 | 404 | 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) | |
| 382 | 409 | defer closer.Close() |
| 383 | 410 | for _, err := range errs { |
| 384 | 411 | notice("%v", err) |
| @@ -448,6 +475,7 @@ func run() error { | ||
| 448 | 475 | render := &ui.Renderer{W: os.Stdout, Verbose: *verbose} |
| 449 | 476 | approver := &ui.Approver{In: in, Out: os.Stdout} |
| 450 | 477 | policy := approval.New(approver.Prompt) |
| 478 | + policy.Rules = cc.Rules | |
| 451 | 479 | |
| 452 | 480 | // Questions go to the line front end until a TUI takes them over in |
| 453 | 481 | // tui.Run. The tool belongs to the session's own agent and is registered |
| @@ -479,6 +507,7 @@ func run() error { | ||
| 479 | 507 | Skills: found, |
| 480 | 508 | ServerTools: serverTools, |
| 481 | 509 | SystemPrefix: systemPrefix, |
| 510 | + Instructions: instructions(root), | |
| 482 | 511 | } |
| 483 | 512 | |
| 484 | 513 | // An agent run hands the whole thing over to the protocol and never comes |
| @@ -501,6 +530,9 @@ func run() error { | ||
| 501 | 530 | Log: log, |
| 502 | 531 | Base: base, |
| 503 | 532 | Tools: toolsIn, |
| 533 | + | |
| 534 | + Rules: rulesFor(ccOpts, root, cc), | |
| 535 | + Instructions: instructions, | |
| 504 | 536 | }) |
| 505 | 537 | } |
| 506 | 538 | |
| @@ -681,6 +713,7 @@ func run() error { | ||
| 681 | 713 | // to come up in the tab whose agent is asking. |
| 682 | 714 | pol := approval.New(nil) |
| 683 | 715 | pol.YOLO = acceptAll |
| 716 | + pol.Rules = cc.Rules | |
| 684 | 717 | |
| 685 | 718 | var tabSup *subagent.Supervisor |
| 686 | 719 | if !*noAgents { |
| @@ -851,6 +884,12 @@ func run() error { | ||
| 851 | 884 | for _, a := range allowed { |
| 852 | 885 | fmt.Println(" " + a) |
| 853 | 886 | } |
| 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 | + } | |
| 854 | 893 | continue |
| 855 | 894 | } |
| 856 | 895 | if err := once(ctx, a, render, sessionDir, session, root, client, line); err != nil { |
| @@ -321,6 +321,13 @@ type Options struct { | ||
| 321 | 321 | NoCache bool |
| 322 | 322 | // Stream uses the SSE endpoint so output appears as it is produced. |
| 323 | 323 | 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 | |
| 324 | 331 | // Skills are the instruction sets discovered at startup. Their names and |
| 325 | 332 | // descriptions are appended to the system prompt; the bodies are loaded on |
| 326 | 333 | // demand by the skill tool. |
| @@ -553,6 +560,7 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log | ||
| 553 | 560 | if opts.System == "" { |
| 554 | 561 | opts.System = defaultSystem |
| 555 | 562 | } |
| 563 | + opts.System += opts.Instructions | |
| 556 | 564 | opts.System += skillListing(opts.Skills) |
| 557 | 565 | opts.System += modelListing(opts.Models) |
| 558 | 566 | return &Agent{client: client, tools: reg, state: state, log: log, opts: opts} |
| @@ -815,6 +815,23 @@ func TestSystemPromptListsSkills(t *testing.T) { | ||
| 815 | 815 | } |
| 816 | 816 | } |
| 817 | 817 | |
| 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 | + | |
| 818 | 835 | func TestSystemPromptUnchangedWithoutSkills(t *testing.T) { |
| 819 | 836 | a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{}) |
| 820 | 837 | if a.opts.System != defaultSystem { |
| @@ -36,6 +36,11 @@ type Request struct { | ||
| 36 | 36 | // agent. Several agents share one prompt box, so a request that does not |
| 37 | 37 | // say who is asking cannot be decided on. |
| 38 | 38 | Agent string |
| 39 | + // Forced names the standing rule that insisted on this prompt — an ask | |
| 40 | + // rule in a settings file — and is empty for an ordinary one. A front end | |
| 41 | + // that settles some prompts by itself, as an ACP permission floor does, | |
| 42 | + // must not settle these: the rule exists to stop exactly that. | |
| 43 | + Forced string | |
| 39 | 44 | } |
| 40 | 45 | |
| 41 | 46 | // Prompt asks a human. Returning an error aborts the turn. |
| @@ -90,6 +95,13 @@ type Policy struct { | ||
| 90 | 95 | // fired around the prompt rather than around Approve, so a call allowed |
| 91 | 96 | // outright or remembered from earlier says nothing. |
| 92 | 97 | Hooks *hooks.Hooks |
| 98 | + | |
| 99 | + // Rules are standing rules from a settings file, consulted before | |
| 100 | + // anything else: before YOLO, before the read-only list and before the | |
| 101 | + // session's memory. A deny there holds in every mode, which is what makes | |
| 102 | + // it worth writing down; an ask there forces a prompt the mode would have | |
| 103 | + // skipped. Nil means none. | |
| 104 | + Rules RuleSet | |
| 93 | 105 | } |
| 94 | 106 | |
| 95 | 107 | // New builds a policy. |
| @@ -108,14 +120,35 @@ func (p *Policy) Approve(ctx context.Context, tool string, input json.RawMessage | ||
| 108 | 120 | // remembered for the whole session, not per agent: "always allow bash" is a |
| 109 | 121 | // statement about this working tree, and an agent id is not a trust boundary. |
| 110 | 122 | func (p *Policy) ApproveAs(ctx context.Context, agent, tool string, input json.RawMessage) error { |
| 111 | - if p == nil || p.YOLO || readOnly[tool] { | |
| 123 | + if p == nil { | |
| 124 | + return nil | |
| 125 | + } | |
| 126 | + // Standing rules first. A deny is the one thing even -yolo does not | |
| 127 | + // override: somebody wrote it down so that it would hold whatever mode the | |
| 128 | + // session happened to be in. A read-only tool is not exempt either — a | |
| 129 | + // rule against reading .env is about reading. | |
| 130 | + ruling := p.check(tool, input) | |
| 131 | + switch ruling.Verdict { | |
| 132 | + case VerdictDeny: | |
| 133 | + return fmt.Errorf("%s was not run: it is denied by the rule %s. Do not retry it; find another way, or tell the user what you need", tool, ruling.because()) | |
| 134 | + case VerdictAllow: | |
| 135 | + return nil | |
| 136 | + } | |
| 137 | + forced := ruling.Verdict == VerdictAsk | |
| 138 | + if !forced && (p.YOLO || readOnly[tool]) { | |
| 112 | 139 | return nil |
| 113 | 140 | } |
| 114 | 141 | req := Describe(tool, input) |
| 115 | 142 | req.Agent = agent |
| 143 | + if forced { | |
| 144 | + req.Forced = ruling.because() | |
| 145 | + req.Summary += " — asked because of " + req.Forced | |
| 146 | + } | |
| 116 | 147 | |
| 148 | + // An ask rule outranks the session's memory too: "always this tool" given | |
| 149 | + // to an earlier prompt was not an answer to a rule that says always ask. | |
| 117 | 150 | p.mu.Lock() |
| 118 | - allowed := p.tools[tool] || (req.Rememberable && p.commands[memoryKey(tool, req.Detail)]) | |
| 151 | + allowed := !forced && (p.tools[tool] || (req.Rememberable && p.commands[memoryKey(tool, req.Detail)])) | |
| 119 | 152 | p.mu.Unlock() |
| 120 | 153 | if allowed { |
| 121 | 154 | return nil |
| @@ -0,0 +1,73 @@ | ||
| 1 | + package approval | |
| 2 | + | |
| 3 | + import "encoding/json" | |
| 4 | + | |
| 5 | + // Verdict is what a set of standing rules says about one tool call, before | |
| 6 | + // anyone is asked and before anything the session remembers is consulted. | |
| 7 | + type Verdict int | |
| 8 | + | |
| 9 | + const ( | |
| 10 | + // NoVerdict means no rule spoke, and the call is decided the ordinary way: | |
| 11 | + // the mode, the session's memory, and then a prompt. | |
| 12 | + NoVerdict Verdict = iota | |
| 13 | + // VerdictAllow lets the call through without a prompt. | |
| 14 | + VerdictAllow | |
| 15 | + // VerdictAsk puts the call to a human even when the mode would have let | |
| 16 | + // it through, and even when the session remembers allowing it. | |
| 17 | + VerdictAsk | |
| 18 | + // VerdictDeny refuses the call outright, in every mode, -yolo included. | |
| 19 | + VerdictDeny | |
| 20 | + ) | |
| 21 | + | |
| 22 | + // Ruling is a verdict and the rule that produced it, for the message the model | |
| 23 | + // reads and the prompt a human reads. "Denied" with no reason is a dead end; | |
| 24 | + // "denied by Bash(rm:*) in ~/.claude/settings.json" is something to fix. | |
| 25 | + type Ruling struct { | |
| 26 | + Verdict Verdict | |
| 27 | + // Rule is the rule as it was written, e.g. Bash(npm run test:*). | |
| 28 | + Rule string | |
| 29 | + // Source is where the rule was written, e.g. ~/.claude/settings.json. | |
| 30 | + Source string | |
| 31 | + } | |
| 32 | + | |
| 33 | + // RuleSet is a set of standing rules that act before the session's own | |
| 34 | + // memory: rules written down in a settings file rather than given as an | |
| 35 | + // answer to a prompt. Claude Code's permissions.allow, deny and ask are the | |
| 36 | + // ones that exist so far. | |
| 37 | + // | |
| 38 | + // It is an interface so this package stays ignorant of where rules come from | |
| 39 | + // and of the grammar they are written in. Whoever loads them decides which | |
| 40 | + // files are honoured; the policy only acts on what they say. | |
| 41 | + type RuleSet interface { | |
| 42 | + // Check rules on one call. Deny outranks ask, which outranks allow, so a | |
| 43 | + // single rule set can never be argued out of a refusal by a broader | |
| 44 | + // permission elsewhere in it. | |
| 45 | + Check(tool string, input json.RawMessage) Ruling | |
| 46 | + // Lines describe the rules for /allowed, grouped by where they came from. | |
| 47 | + Lines() []string | |
| 48 | + } | |
| 49 | + | |
| 50 | + // check is Rules.Check, tolerating a policy with no rules. | |
| 51 | + func (p *Policy) check(tool string, input json.RawMessage) Ruling { | |
| 52 | + if p.Rules == nil { | |
| 53 | + return Ruling{} | |
| 54 | + } | |
| 55 | + return p.Rules.Check(tool, input) | |
| 56 | + } | |
| 57 | + | |
| 58 | + // RuleLines is what /allowed shows under the session's own memory: the | |
| 59 | + // standing rules, and the files they came from. Nil when there are none. | |
| 60 | + func (p *Policy) RuleLines() []string { | |
| 61 | + if p == nil || p.Rules == nil { | |
| 62 | + return nil | |
| 63 | + } | |
| 64 | + return p.Rules.Lines() | |
| 65 | + } | |
| 66 | + | |
| 67 | + // because names the rule behind a ruling, for a message. | |
| 68 | + func (r Ruling) because() string { | |
| 69 | + if r.Source == "" { | |
| 70 | + return r.Rule | |
| 71 | + } | |
| 72 | + return r.Rule + " in " + r.Source | |
| 73 | + } | |
| @@ -0,0 +1,143 @@ | ||
| 1 | + package approval | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "reflect" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + // fixedRules rules the same way on every call to one tool. | |
| 12 | + type fixedRules struct { | |
| 13 | + tool string | |
| 14 | + verdict Verdict | |
| 15 | + } | |
| 16 | + | |
| 17 | + func (f fixedRules) Check(tool string, _ json.RawMessage) Ruling { | |
| 18 | + if tool != f.tool { | |
| 19 | + return Ruling{} | |
| 20 | + } | |
| 21 | + return Ruling{Verdict: f.verdict, Rule: "Rule(x)", Source: "settings.json"} | |
| 22 | + } | |
| 23 | + | |
| 24 | + func (f fixedRules) Lines() []string { return []string{"settings.json", " rule"} } | |
| 25 | + | |
| 26 | + func TestStandingRules(t *testing.T) { | |
| 27 | + tests := []struct { | |
| 28 | + name string | |
| 29 | + tool string | |
| 30 | + verdict Verdict | |
| 31 | + yolo bool | |
| 32 | + remembered bool | |
| 33 | + wantErr bool | |
| 34 | + wantPrompt int | |
| 35 | + }{ | |
| 36 | + {name: "deny holds under yolo", tool: "bash", verdict: VerdictDeny, yolo: true, wantErr: true}, | |
| 37 | + {name: "deny holds for a read-only tool", tool: "read_file", verdict: VerdictDeny, wantErr: true}, | |
| 38 | + {name: "deny beats session memory", tool: "bash", verdict: VerdictDeny, remembered: true, wantErr: true}, | |
| 39 | + {name: "allow skips the prompt", tool: "bash", verdict: VerdictAllow}, | |
| 40 | + {name: "ask prompts under yolo", tool: "bash", verdict: VerdictAsk, yolo: true, wantPrompt: 1}, | |
| 41 | + {name: "ask prompts for a read-only tool", tool: "read_file", verdict: VerdictAsk, wantPrompt: 1}, | |
| 42 | + {name: "ask beats session memory", tool: "bash", verdict: VerdictAsk, remembered: true, wantPrompt: 1}, | |
| 43 | + {name: "no verdict prompts as usual", tool: "bash", verdict: NoVerdict, wantPrompt: 1}, | |
| 44 | + {name: "no verdict still honours memory", tool: "bash", verdict: NoVerdict, remembered: true}, | |
| 45 | + } | |
| 46 | + for _, tc := range tests { | |
| 47 | + t.Run(tc.name, func(t *testing.T) { | |
| 48 | + var asked int | |
| 49 | + var forced string | |
| 50 | + p := New(func(_ context.Context, r Request) (Decision, error) { | |
| 51 | + asked++ | |
| 52 | + forced = r.Forced | |
| 53 | + return Once, nil | |
| 54 | + }) | |
| 55 | + p.YOLO = tc.yolo | |
| 56 | + p.Rules = fixedRules{tool: tc.tool, verdict: tc.verdict} | |
| 57 | + if tc.remembered { | |
| 58 | + p.tools[tc.tool] = true | |
| 59 | + } | |
| 60 | + err := approve(t, p, tc.tool, `{"command":"ls","path":"a"}`) | |
| 61 | + if (err != nil) != tc.wantErr { | |
| 62 | + t.Fatalf("err = %v, want error %v", err, tc.wantErr) | |
| 63 | + } | |
| 64 | + if err != nil && !strings.Contains(err.Error(), "Rule(x) in settings.json") { | |
| 65 | + t.Errorf("denial %q should name the rule and its file", err) | |
| 66 | + } | |
| 67 | + if asked != tc.wantPrompt { | |
| 68 | + t.Errorf("prompted %d times, want %d", asked, tc.wantPrompt) | |
| 69 | + } | |
| 70 | + if tc.verdict == VerdictAsk && forced == "" { | |
| 71 | + t.Error("a prompt an ask rule forced should say so") | |
| 72 | + } | |
| 73 | + }) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + // Headless, an ask rule is a refusal with nobody to put the question to — | |
| 78 | + // which is what the rule asked for. | |
| 79 | + func TestAskRuleHeadlessDenies(t *testing.T) { | |
| 80 | + p := New(nil) | |
| 81 | + p.YOLO = true | |
| 82 | + p.Rules = fixedRules{tool: "bash", verdict: VerdictAsk} | |
| 83 | + if err := approve(t, p, "bash", `{"command":"ls"}`); err == nil { | |
| 84 | + t.Error("an ask rule with no prompt should not run the call") | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + func TestRuleLines(t *testing.T) { | |
| 89 | + var p *Policy | |
| 90 | + if p.RuleLines() != nil { | |
| 91 | + t.Error("nil policy") | |
| 92 | + } | |
| 93 | + p = New(nil) | |
| 94 | + if p.RuleLines() != nil { | |
| 95 | + t.Error("no rules") | |
| 96 | + } | |
| 97 | + p.Rules = fixedRules{} | |
| 98 | + if len(p.RuleLines()) != 2 { | |
| 99 | + t.Error("lines not passed through") | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + func TestSimpleCommands(t *testing.T) { | |
| 104 | + tests := []struct { | |
| 105 | + script string | |
| 106 | + want []string | |
| 107 | + pinned []bool | |
| 108 | + opaque bool | |
| 109 | + }{ | |
| 110 | + {"git status", []string{"git status"}, []bool{true}, false}, | |
| 111 | + {"go build ./... && go test ./...", []string{"go build ./...", "go test ./..."}, []bool{true, true}, false}, | |
| 112 | + {"ls | grep x; echo done", []string{"ls", "grep x", "echo done"}, []bool{true, true, true}, false}, | |
| 113 | + {"(cd sub && make)", []string{"cd sub", "make"}, []bool{true, true}, false}, | |
| 114 | + {`git commit -m "fix: the thing"`, []string{"git commit -m fix: the thing"}, []bool{true}, false}, | |
| 115 | + {"rm $FILE", []string{"rm <expanded>"}, []bool{true}, false}, | |
| 116 | + {"ls *.go", []string{"ls <expanded>"}, []bool{true}, false}, | |
| 117 | + {"FOO=1 make", []string{"make"}, []bool{false}, false}, | |
| 118 | + {"echo x > out.txt", []string{"echo x"}, []bool{false}, false}, | |
| 119 | + {"echo x >> out.txt", []string{"echo x"}, []bool{false}, false}, | |
| 120 | + {"echo x 2>&1 >/dev/null", []string{"echo x"}, []bool{true}, false}, | |
| 121 | + {"export A=1", []string{"export"}, []bool{false}, false}, | |
| 122 | + {"echo $(whoami)", []string{"echo <expanded>", "whoami"}, []bool{true, true}, true}, | |
| 123 | + {"diff <(ls a) <(ls b)", []string{"diff <expanded> <expanded>", "ls a", "ls b"}, []bool{true, true, true}, true}, | |
| 124 | + } | |
| 125 | + for _, tc := range tests { | |
| 126 | + cmds, info := SimpleCommands(tc.script) | |
| 127 | + var got []string | |
| 128 | + var pinned []bool | |
| 129 | + for _, c := range cmds { | |
| 130 | + got = append(got, c.Text()) | |
| 131 | + pinned = append(pinned, c.Pinned) | |
| 132 | + } | |
| 133 | + if !reflect.DeepEqual(got, tc.want) || !reflect.DeepEqual(pinned, tc.pinned) { | |
| 134 | + t.Errorf("%q: got %q %v, want %q %v", tc.script, got, pinned, tc.want, tc.pinned) | |
| 135 | + } | |
| 136 | + if info.Opaque != tc.opaque { | |
| 137 | + t.Errorf("%q: opaque %v, want %v", tc.script, info.Opaque, tc.opaque) | |
| 138 | + } | |
| 139 | + } | |
| 140 | + if cmds, info := SimpleCommands("echo 'open"); cmds != nil || info.ParseError == nil { | |
| 141 | + t.Error("a parse error should yield nothing") | |
| 142 | + } | |
| 143 | + } | |
| @@ -0,0 +1,158 @@ | ||
| 1 | + package approval | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "strings" | |
| 5 | + | |
| 6 | + "mvdan.cc/sh/v3/syntax" | |
| 7 | + ) | |
| 8 | + | |
| 9 | + // SimpleCommand is one command a shell script runs, as its words. | |
| 10 | + // | |
| 11 | + // It exists for rules written as command prefixes — Claude Code's | |
| 12 | + // Bash(npm run test:*) — which can only be matched honestly one simple command | |
| 13 | + // at a time. Matched against the whole line, "npm run test && rm -rf ~" starts | |
| 14 | + // with "npm run test", and an allow rule written for the first half would | |
| 15 | + // quietly allow the second. | |
| 16 | + type SimpleCommand struct { | |
| 17 | + // Words are the command's words after quote removal: "git", "commit", | |
| 18 | + // "-m", "fix it". A word the shell would build at run time — a variable, | |
| 19 | + // a glob, a backslash escape this reader does not undo — is left empty, | |
| 20 | + // and Literal says so. | |
| 21 | + Words []string | |
| 22 | + // Literal is parallel to Words: true where the word is fixed text. | |
| 23 | + Literal []bool | |
| 24 | + // Pinned is false when something about this command is decided by more | |
| 25 | + // than its words: a variable assignment in front of it, which can change | |
| 26 | + // what the name resolves to (PATH=. git), or an output redirection to a | |
| 27 | + // file, which writes somewhere no prefix of the words mentions. | |
| 28 | + Pinned bool | |
| 29 | + } | |
| 30 | + | |
| 31 | + // Text is the command as words joined by single spaces, with a placeholder | |
| 32 | + // where a word is not fixed. It is for messages, not for matching. | |
| 33 | + func (c SimpleCommand) Text() string { | |
| 34 | + out := make([]string, len(c.Words)) | |
| 35 | + for i, w := range c.Words { | |
| 36 | + if !c.Literal[i] { | |
| 37 | + w = "<expanded>" | |
| 38 | + } | |
| 39 | + out[i] = w | |
| 40 | + } | |
| 41 | + return strings.Join(out, " ") | |
| 42 | + } | |
| 43 | + | |
| 44 | + // SimpleCommands splits a script into every simple command it contains, | |
| 45 | + // however they are joined: ;, &&, ||, pipes, subshells, the bodies of loops, | |
| 46 | + // conditionals and functions. The ShellInfo is Analyze's for the same script; | |
| 47 | + // when it is opaque, the list is what could be seen and not necessarily | |
| 48 | + // everything that will run, so a caller deciding to allow something must | |
| 49 | + // treat an opaque script as matching nothing. | |
| 50 | + func SimpleCommands(script string) ([]SimpleCommand, ShellInfo) { | |
| 51 | + info := Analyze(script) | |
| 52 | + if info.ParseError != nil { | |
| 53 | + return nil, info | |
| 54 | + } | |
| 55 | + f, err := syntax.NewParser().Parse(strings.NewReader(script), "") | |
| 56 | + if err != nil { | |
| 57 | + return nil, info | |
| 58 | + } | |
| 59 | + var out []SimpleCommand | |
| 60 | + syntax.Walk(f, func(node syntax.Node) bool { | |
| 61 | + switch n := node.(type) { | |
| 62 | + case *syntax.ProcSubst: | |
| 63 | + // <(cmd) runs cmd as surely as $(cmd) does, and Analyze does not | |
| 64 | + // look for it, so it is marked here. | |
| 65 | + info.Opaque, info.Reason = true, "it contains process substitution, so what runs is decided at run time" | |
| 66 | + case *syntax.Stmt: | |
| 67 | + call, ok := n.Cmd.(*syntax.CallExpr) | |
| 68 | + if ok && len(call.Args) > 0 { | |
| 69 | + out = append(out, simple(call, n.Redirs)) | |
| 70 | + return true | |
| 71 | + } | |
| 72 | + if decl, ok := n.Cmd.(*syntax.DeclClause); ok { | |
| 73 | + // export, local, declare: a builtin with assignments for | |
| 74 | + // arguments. Kept as a command so a rule must account for it, | |
| 75 | + // never pinned, because an assignment is what it does. | |
| 76 | + out = append(out, SimpleCommand{Words: []string{decl.Variant.Value}, Literal: []bool{true}}) | |
| 77 | + } | |
| 78 | + } | |
| 79 | + return true | |
| 80 | + }) | |
| 81 | + return out, info | |
| 82 | + } | |
| 83 | + | |
| 84 | + func simple(call *syntax.CallExpr, redirs []*syntax.Redirect) SimpleCommand { | |
| 85 | + c := SimpleCommand{Pinned: len(call.Assigns) == 0} | |
| 86 | + for _, w := range call.Args { | |
| 87 | + text, ok := unquote(w) | |
| 88 | + c.Words = append(c.Words, text) | |
| 89 | + c.Literal = append(c.Literal, ok) | |
| 90 | + } | |
| 91 | + for _, r := range redirs { | |
| 92 | + if writesFile(r) { | |
| 93 | + c.Pinned = false | |
| 94 | + } | |
| 95 | + } | |
| 96 | + return c | |
| 97 | + } | |
| 98 | + | |
| 99 | + // unquote returns a word's text after quote removal when the text is fixed, | |
| 100 | + // and false when any part of it is expanded at run time. | |
| 101 | + func unquote(w *syntax.Word) (string, bool) { | |
| 102 | + var b strings.Builder | |
| 103 | + for _, part := range w.Parts { | |
| 104 | + switch p := part.(type) { | |
| 105 | + case *syntax.Lit: | |
| 106 | + // A backslash in an unquoted literal is an escape the shell will | |
| 107 | + // remove; rather than reimplement that, the word is not fixed. | |
| 108 | + // Globs and a leading ~ are expanded by the shell, so they are | |
| 109 | + // not fixed either. | |
| 110 | + if strings.ContainsAny(p.Value, "\\*?[") || (b.Len() == 0 && strings.HasPrefix(p.Value, "~")) { | |
| 111 | + return "", false | |
| 112 | + } | |
| 113 | + b.WriteString(p.Value) | |
| 114 | + case *syntax.SglQuoted: | |
| 115 | + if p.Dollar { | |
| 116 | + return "", false | |
| 117 | + } | |
| 118 | + b.WriteString(p.Value) | |
| 119 | + case *syntax.DblQuoted: | |
| 120 | + for _, inner := range p.Parts { | |
| 121 | + lit, ok := inner.(*syntax.Lit) | |
| 122 | + if !ok || strings.Contains(lit.Value, "\\") { | |
| 123 | + return "", false | |
| 124 | + } | |
| 125 | + b.WriteString(lit.Value) | |
| 126 | + } | |
| 127 | + default: | |
| 128 | + return "", false | |
| 129 | + } | |
| 130 | + } | |
| 131 | + return b.String(), true | |
| 132 | + } | |
| 133 | + | |
| 134 | + // writesFile reports whether a redirection writes to a file. Duplicating a | |
| 135 | + // descriptor (2>&1) and throwing output away (>/dev/null) do not. | |
| 136 | + func writesFile(r *syntax.Redirect) bool { | |
| 137 | + switch r.Op { | |
| 138 | + case syntax.RdrIn, syntax.Hdoc, syntax.DashHdoc, syntax.WordHdoc, syntax.DplIn: | |
| 139 | + return false | |
| 140 | + case syntax.DplOut: | |
| 141 | + target, ok := unquote(r.Word) | |
| 142 | + return !ok || !(target == "-" || isDigits(target)) | |
| 143 | + } | |
| 144 | + target, ok := unquote(r.Word) | |
| 145 | + return !ok || target != "/dev/null" | |
| 146 | + } | |
| 147 | + | |
| 148 | + func isDigits(s string) bool { | |
| 149 | + if s == "" { | |
| 150 | + return false | |
| 151 | + } | |
| 152 | + for _, r := range s { | |
| 153 | + if r < '0' || r > '9' { | |
| 154 | + return false | |
| 155 | + } | |
| 156 | + } | |
| 157 | + return true | |
| 158 | + } | |
| @@ -0,0 +1,96 @@ | ||
| 1 | + // Package ccsettings reads the configuration Claude Code keeps for a user and | |
| 2 | + // for a project, so that someone who already set Claude Code up does not have | |
| 3 | + // to set Codey up a second time: the MCP servers it knows about, the | |
| 4 | + // permission rules in its settings files, and the CLAUDE.md files it loads | |
| 5 | + // into every conversation. | |
| 6 | + // | |
| 7 | + // Reading is the easy half. The half that needs care is what is honoured, and | |
| 8 | + // it turns on who wrote each file. ~/.claude.json and ~/.claude/settings.json | |
| 9 | + // are the user's own, written by the user or by `claude` on their behalf, and | |
| 10 | + // they carry the same weight as Codey's own config. A project's .mcp.json and | |
| 11 | + // .claude/settings.json are committed to the repository, which means they were | |
| 12 | + // written by whoever wrote the repository: starting a server listed there is | |
| 13 | + // running a command a stranger chose, and an allow rule there is a stranger | |
| 14 | + // deciding what runs without asking. Those two are honoured only once the user | |
| 15 | + // has said, for this checkout and these exact definitions, that they trust | |
| 16 | + // them — the same bargain Claude Code's own trust dialog strikes. Anything | |
| 17 | + // that can only take power away — a deny rule, an ask rule — is honoured from | |
| 18 | + // every file, because nobody is harmed by an agent that asks more often. | |
| 19 | + // | |
| 20 | + // Nothing here writes to Claude Code's files. The one file this package | |
| 21 | + // writes is Codey's own record of those trust decisions. | |
| 22 | + package ccsettings | |
| 23 | + | |
| 24 | + import ( | |
| 25 | + "encoding/json" | |
| 26 | + "errors" | |
| 27 | + "fmt" | |
| 28 | + "io/fs" | |
| 29 | + "os" | |
| 30 | + "path/filepath" | |
| 31 | + "strings" | |
| 32 | + ) | |
| 33 | + | |
| 34 | + // Scope is who a setting belongs to, which is what decides whether it is | |
| 35 | + // honoured without asking. | |
| 36 | + type Scope string | |
| 37 | + | |
| 38 | + const ( | |
| 39 | + // ScopeUser is the user's own, for every project: ~/.claude/settings.json, | |
| 40 | + // and the top-level servers in ~/.claude.json. | |
| 41 | + ScopeUser Scope = "user" | |
| 42 | + // ScopeProject is committed to the repository: .mcp.json and | |
| 43 | + // .claude/settings.json at the working root. Written by whoever wrote the | |
| 44 | + // repository, so what it grants waits for trust. | |
| 45 | + ScopeProject Scope = "project" | |
| 46 | + // ScopeLocal is the user's own, for this project only: | |
| 47 | + // .claude/settings.local.json, which Claude Code keeps out of git, and the | |
| 48 | + // per-project servers in ~/.claude.json, which `claude mcp add` writes. | |
| 49 | + ScopeLocal Scope = "local" | |
| 50 | + ) | |
| 51 | + | |
| 52 | + // File names, relative to the home directory or the working root. | |
| 53 | + const ( | |
| 54 | + ClaudeJSON = ".claude.json" | |
| 55 | + UserSettings = ".claude/settings.json" | |
| 56 | + ProjectSettings = ".claude/settings.json" | |
| 57 | + LocalSettings = ".claude/settings.local.json" | |
| 58 | + ProjectMCP = ".mcp.json" | |
| 59 | + UserInstructions = ".claude/CLAUDE.md" | |
| 60 | + instructionsName = "CLAUDE.md" | |
| 61 | + localInstructions = "CLAUDE.local.md" | |
| 62 | + ) | |
| 63 | + | |
| 64 | + // readJSON decodes a file into v. A missing file is (false, nil): every file | |
| 65 | + // here is optional, and most machines have none of them. | |
| 66 | + func readJSON(path string, v any) (bool, error) { | |
| 67 | + raw, err := os.ReadFile(path) | |
| 68 | + if errors.Is(err, fs.ErrNotExist) { | |
| 69 | + return false, nil | |
| 70 | + } | |
| 71 | + if err != nil { | |
| 72 | + return false, err | |
| 73 | + } | |
| 74 | + if err := json.Unmarshal(raw, v); err != nil { | |
| 75 | + return false, fmt.Errorf("%s is not valid JSON: %w", path, err) | |
| 76 | + } | |
| 77 | + return true, nil | |
| 78 | + } | |
| 79 | + | |
| 80 | + // shown is how a path is written in a message: under ~ when it is in the home | |
| 81 | + // directory, relative when it is in the working root, absolute otherwise. A | |
| 82 | + // prompt that says ".mcp.json" is one a person reads; one that says | |
| 83 | + // /home/someone/src/thing/.mcp.json is one they skim. | |
| 84 | + func shown(home, root, path string) string { | |
| 85 | + if root != "" { | |
| 86 | + if rel, err := filepath.Rel(root, path); err == nil && !strings.HasPrefix(rel, "..") { | |
| 87 | + return filepath.ToSlash(rel) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + if home != "" { | |
| 91 | + if rel, err := filepath.Rel(home, path); err == nil && !strings.HasPrefix(rel, "..") { | |
| 92 | + return "~/" + filepath.ToSlash(rel) | |
| 93 | + } | |
| 94 | + } | |
| 95 | + return path | |
| 96 | + } | |
| @@ -0,0 +1,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 | + } | |
| @@ -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 | + } | |
| @@ -0,0 +1,293 @@ | ||
| 1 | + package ccsettings | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "os" | |
| 5 | + "os/exec" | |
| 6 | + "path/filepath" | |
| 7 | + "reflect" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + | |
| 11 | + "github.com/johannSo/codey/internal/config" | |
| 12 | + ) | |
| 13 | + | |
| 14 | + // write puts a file under dir, creating its parents. Every test here builds | |
| 15 | + // its own home and root under t.TempDir: nothing may read the real ~/.claude*. | |
| 16 | + func write(t *testing.T, dir, rel, body string) string { | |
| 17 | + t.Helper() | |
| 18 | + path := filepath.Join(dir, rel) | |
| 19 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 20 | + t.Fatal(err) | |
| 21 | + } | |
| 22 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 23 | + t.Fatal(err) | |
| 24 | + } | |
| 25 | + return path | |
| 26 | + } | |
| 27 | + | |
| 28 | + func dirs(t *testing.T) (home, root string) { | |
| 29 | + t.Helper() | |
| 30 | + base := t.TempDir() | |
| 31 | + home = filepath.Join(base, "home") | |
| 32 | + root = filepath.Join(home, "src", "proj") | |
| 33 | + if err := os.MkdirAll(root, 0o755); err != nil { | |
| 34 | + t.Fatal(err) | |
| 35 | + } | |
| 36 | + return home, root | |
| 37 | + } | |
| 38 | + | |
| 39 | + func TestLoadPermissionsReadsTheThreeFiles(t *testing.T) { | |
| 40 | + home, root := dirs(t) | |
| 41 | + write(t, home, UserSettings, `{"permissions":{"allow":["Bash(go test:*)"],"defaultMode":"acceptEdits"},"model":"opus","env":{"X":"1"}}`) | |
| 42 | + write(t, root, ProjectSettings, `{"permissions":{"allow":["Bash(make:*)"],"deny":["Read(.env)"]}}`) | |
| 43 | + write(t, root, LocalSettings, `{"permissions":{"ask":["Bash(git push:*)"]}}`) | |
| 44 | + | |
| 45 | + got, errs := LoadPermissions(home, root) | |
| 46 | + if len(errs) != 0 { | |
| 47 | + t.Fatalf("errors: %v", errs) | |
| 48 | + } | |
| 49 | + want := []Permissions{ | |
| 50 | + {Scope: ScopeUser, Shown: "~/.claude/settings.json", Allow: []string{"Bash(go test:*)"}, DefaultMode: "acceptEdits"}, | |
| 51 | + {Scope: ScopeProject, Shown: ".claude/settings.json", Allow: []string{"Bash(make:*)"}, Deny: []string{"Read(.env)"}}, | |
| 52 | + {Scope: ScopeLocal, Shown: ".claude/settings.local.json", Ask: []string{"Bash(git push:*)"}}, | |
| 53 | + } | |
| 54 | + for i := range got { | |
| 55 | + got[i].Path = "" | |
| 56 | + } | |
| 57 | + if !reflect.DeepEqual(got, want) { | |
| 58 | + t.Errorf("got %+v\nwant %+v", got, want) | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + func TestLoadPermissionsShapes(t *testing.T) { | |
| 63 | + tests := []struct { | |
| 64 | + name string | |
| 65 | + body string | |
| 66 | + wantFiles int | |
| 67 | + wantErr bool | |
| 68 | + }{ | |
| 69 | + {"no file", "", 0, false}, | |
| 70 | + {"empty object", `{}`, 1, false}, | |
| 71 | + {"no permissions block", `{"hooks":{"PreToolUse":[]}}`, 1, false}, | |
| 72 | + {"broken JSON", `{"permissions":`, 0, true}, | |
| 73 | + } | |
| 74 | + for _, tc := range tests { | |
| 75 | + t.Run(tc.name, func(t *testing.T) { | |
| 76 | + home, root := dirs(t) | |
| 77 | + if tc.body != "" { | |
| 78 | + write(t, root, ProjectSettings, tc.body) | |
| 79 | + } | |
| 80 | + got, errs := LoadPermissions(home, root) | |
| 81 | + if len(got) != tc.wantFiles || (len(errs) > 0) != tc.wantErr { | |
| 82 | + t.Errorf("files %d errs %v, want %d files, error %v", len(got), errs, tc.wantFiles, tc.wantErr) | |
| 83 | + } | |
| 84 | + }) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + // Started in the home directory, the user's settings are the user's, not a | |
| 89 | + // project's: reading them a second time as shared project settings would hold | |
| 90 | + // their allow rules hostage to a trust prompt about themselves. | |
| 91 | + func TestLoadPermissionsRootIsHome(t *testing.T) { | |
| 92 | + home, _ := dirs(t) | |
| 93 | + write(t, home, UserSettings, `{"permissions":{"allow":["Edit"]}}`) | |
| 94 | + got, _ := LoadPermissions(home, home) | |
| 95 | + if len(got) != 1 || got[0].Scope != ScopeUser { | |
| 96 | + t.Errorf("got %+v, want only the user file", got) | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + // A settings.local.json is supposed to be kept out of git. One that git is | |
| 101 | + // tracking arrived with the clone, and gets no more trust than the shared | |
| 102 | + // file. | |
| 103 | + func TestTrackedLocalSettingsAreProjectScope(t *testing.T) { | |
| 104 | + if _, err := exec.LookPath("git"); err != nil { | |
| 105 | + t.Skip("no git") | |
| 106 | + } | |
| 107 | + home, root := dirs(t) | |
| 108 | + write(t, root, LocalSettings, `{"permissions":{"allow":["Bash"]}}`) | |
| 109 | + for _, args := range [][]string{{"init", "-q"}, {"add", "-f", LocalSettings}} { | |
| 110 | + cmd := exec.Command("git", args...) | |
| 111 | + cmd.Dir = root | |
| 112 | + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") | |
| 113 | + if out, err := cmd.CombinedOutput(); err != nil { | |
| 114 | + t.Fatalf("git %v: %v\n%s", args, err, out) | |
| 115 | + } | |
| 116 | + } | |
| 117 | + got, _ := LoadPermissions(home, root) | |
| 118 | + if len(got) != 1 || got[0].Scope != ScopeProject || !got[0].Tracked { | |
| 119 | + t.Fatalf("got %+v, want a tracked, project-scope file", got) | |
| 120 | + } | |
| 121 | + rs, _ := NewRuleset(home, root, got, false) | |
| 122 | + if v := rs.Check("bash", []byte(`{"command":"ls"}`)); v.Verdict != 0 { | |
| 123 | + t.Errorf("an untrusted, committed local file's allow rule was honoured: %+v", v) | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + func TestLoadMCPShapes(t *testing.T) { | |
| 128 | + home, root := dirs(t) | |
| 129 | + write(t, root, ProjectMCP, `{"mcpServers":{ | |
| 130 | + "plain": {"command":"plain-mcp","args":["--x"]}, | |
| 131 | + "typed": {"type":"stdio","command":"typed-mcp","env":{"K":"v"}}, | |
| 132 | + "web": {"type":"http","url":"https://example.com/mcp"}, | |
| 133 | + "old": {"type":"sse","url":"https://example.com/sse"}, | |
| 134 | + "bare": {"url":"https://example.com/u"} | |
| 135 | + }}`) | |
| 136 | + write(t, home, ClaudeJSON, `{ | |
| 137 | + "numStartups": 12, | |
| 138 | + "mcpServers": {"mine": {"command":"mine-mcp"}}, | |
| 139 | + "projects": { | |
| 140 | + "`+root+`": {"mcpServers": {"here": {"command":"here-mcp"}}, "allowedTools": []}, | |
| 141 | + "/elsewhere": {"mcpServers": {"there": {"command":"there-mcp"}}} | |
| 142 | + } | |
| 143 | + }`) | |
| 144 | + | |
| 145 | + files, errs := LoadMCP(home, root) | |
| 146 | + if len(errs) != 3 { | |
| 147 | + t.Errorf("want one error per remote server, got %v", errs) | |
| 148 | + } | |
| 149 | + for _, err := range errs { | |
| 150 | + if !strings.Contains(err.Error(), "stdio") { | |
| 151 | + t.Errorf("error %q should say Codey speaks only stdio", err) | |
| 152 | + } | |
| 153 | + } | |
| 154 | + var got []string | |
| 155 | + for _, f := range files { | |
| 156 | + got = append(got, string(f.Scope)+":"+strings.Join(sortedNames(f.Servers), ",")) | |
| 157 | + } | |
| 158 | + want := []string{"local:here", "project:plain,typed", "user:mine"} | |
| 159 | + if !reflect.DeepEqual(got, want) { | |
| 160 | + t.Errorf("files %v, want %v (most specific first)", got, want) | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + // Claude Code keys a project by the path it was started in; a root reached | |
| 165 | + // through a symlink is still that project. | |
| 166 | + func TestLoadMCPFindsProjectThroughSymlink(t *testing.T) { | |
| 167 | + home, root := dirs(t) | |
| 168 | + link := filepath.Join(filepath.Dir(home), "link") | |
| 169 | + if err := os.Symlink(root, link); err != nil { | |
| 170 | + t.Skip(err) | |
| 171 | + } | |
| 172 | + write(t, home, ClaudeJSON, `{"projects":{"`+root+`":{"mcpServers":{"here":{"command":"x"}}}}}`) | |
| 173 | + files, _ := LoadMCP(home, link) | |
| 174 | + if len(files) != 1 || files[0].Scope != ScopeLocal { | |
| 175 | + t.Errorf("got %+v", files) | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + func TestLoadMCPBrokenFileIsReported(t *testing.T) { | |
| 180 | + home, root := dirs(t) | |
| 181 | + write(t, root, ProjectMCP, `{"mcpServers": [`) | |
| 182 | + files, errs := LoadMCP(home, root) | |
| 183 | + if len(files) != 0 || len(errs) != 1 { | |
| 184 | + t.Errorf("files %v errs %v", files, errs) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + func env(m map[string]string) func(string) (string, bool) { | |
| 189 | + return func(k string) (string, bool) { v, ok := m[k]; return v, ok } | |
| 190 | + } | |
| 191 | + | |
| 192 | + func TestMergeMCPPrecedence(t *testing.T) { | |
| 193 | + files := []MCPFile{ | |
| 194 | + {Scope: ScopeLocal, Shown: "~/.claude.json (this project)", Servers: map[string]ServerDef{ | |
| 195 | + "db": {Command: "local-db"}, "only-local": {Command: "l"}}}, | |
| 196 | + {Scope: ScopeProject, Shown: ".mcp.json", Servers: map[string]ServerDef{ | |
| 197 | + "db": {Command: "project-db"}, "fs": {Command: "project-fs"}, "lint": {Command: "project-lint"}}}, | |
| 198 | + {Scope: ScopeUser, Shown: "~/.claude.json", Servers: map[string]ServerDef{ | |
| 199 | + "fs": {Command: "user-fs"}, "lint": {Command: "user-lint"}, "only-user": {Command: "u"}}}, | |
| 200 | + } | |
| 201 | + own := map[string]config.MCPServer{"lint": {Command: "codey-lint"}} | |
| 202 | + | |
| 203 | + tests := []struct { | |
| 204 | + name string | |
| 205 | + trusted bool | |
| 206 | + want map[string]string | |
| 207 | + wantNotes int | |
| 208 | + }{ | |
| 209 | + { | |
| 210 | + name: "trusted", | |
| 211 | + trusted: true, | |
| 212 | + want: map[string]string{ | |
| 213 | + "lint": "codey-lint", "db": "local-db", "fs": "project-fs", | |
| 214 | + "only-local": "l", "only-user": "u", | |
| 215 | + }, | |
| 216 | + // db: local over project; lint: codey over project and user; | |
| 217 | + // fs: project over user. | |
| 218 | + wantNotes: 4, | |
| 219 | + }, | |
| 220 | + { | |
| 221 | + name: "untrusted project servers give way to the user's own", | |
| 222 | + trusted: false, | |
| 223 | + want: map[string]string{ | |
| 224 | + "lint": "codey-lint", "db": "local-db", "fs": "user-fs", | |
| 225 | + "only-local": "l", "only-user": "u", | |
| 226 | + }, | |
| 227 | + wantNotes: 1, | |
| 228 | + }, | |
| 229 | + } | |
| 230 | + for _, tc := range tests { | |
| 231 | + t.Run(tc.name, func(t *testing.T) { | |
| 232 | + got, notes := MergeMCP(own, files, tc.trusted, env(nil)) | |
| 233 | + commands := map[string]string{} | |
| 234 | + for n, s := range got { | |
| 235 | + commands[n] = s.Command | |
| 236 | + } | |
| 237 | + if !reflect.DeepEqual(commands, tc.want) { | |
| 238 | + t.Errorf("got %v, want %v", commands, tc.want) | |
| 239 | + } | |
| 240 | + if len(notes) != tc.wantNotes { | |
| 241 | + t.Errorf("notes %q, want %d", notes, tc.wantNotes) | |
| 242 | + } | |
| 243 | + for _, n := range notes { | |
| 244 | + if !strings.Contains(n, "using the one in") { | |
| 245 | + t.Errorf("clash note %q should name the winner", n) | |
| 246 | + } | |
| 247 | + } | |
| 248 | + }) | |
| 249 | + } | |
| 250 | + } | |
| 251 | + | |
| 252 | + func TestExpand(t *testing.T) { | |
| 253 | + vars := env(map[string]string{"HOME": "/h", "EMPTY": "", "PORT": "5432"}) | |
| 254 | + tests := []struct { | |
| 255 | + in, want string | |
| 256 | + wantErr bool | |
| 257 | + }{ | |
| 258 | + {"plain", "plain", false}, | |
| 259 | + {"${HOME}/bin/tool", "/h/bin/tool", false}, | |
| 260 | + {"--port=${PORT}", "--port=5432", false}, | |
| 261 | + {"${MISSING:-fallback}", "fallback", false}, | |
| 262 | + {"${EMPTY:-fallback}", "fallback", false}, | |
| 263 | + {"${HOME:-unused}", "/h", false}, | |
| 264 | + {"${MISSING:-}", "", false}, | |
| 265 | + {"${A:-x}${B:-y}", "xy", false}, | |
| 266 | + {"$HOME stays", "$HOME stays", false}, | |
| 267 | + {"${MISSING}", "", true}, | |
| 268 | + } | |
| 269 | + for _, tc := range tests { | |
| 270 | + got, err := expand(tc.in, vars) | |
| 271 | + if (err != nil) != tc.wantErr || got != tc.want { | |
| 272 | + t.Errorf("expand(%q) = %q, %v; want %q, error %v", tc.in, got, err, tc.want, tc.wantErr) | |
| 273 | + } | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 277 | + func TestMergeMCPExpandsEverywhere(t *testing.T) { | |
| 278 | + files := []MCPFile{{Scope: ScopeUser, Shown: "~/.claude.json", Servers: map[string]ServerDef{ | |
| 279 | + "db": {Command: "${BIN:-/usr/bin}/db", Args: []string{"--url", "${DB_URL}"}, Env: map[string]string{"TOKEN": "${TOKEN}"}}, | |
| 280 | + "broken": {Command: "x", Args: []string{"${NOPE}"}}, | |
| 281 | + }}} | |
| 282 | + got, notes := MergeMCP(nil, files, false, env(map[string]string{"DB_URL": "pg://x", "TOKEN": "t"})) | |
| 283 | + want := config.MCPServer{Command: "/usr/bin/db", Args: []string{"--url", "pg://x"}, Env: map[string]string{"TOKEN": "t"}} | |
| 284 | + if !reflect.DeepEqual(got["db"], want) { | |
| 285 | + t.Errorf("db = %+v, want %+v", got["db"], want) | |
| 286 | + } | |
| 287 | + if _, ok := got["broken"]; ok { | |
| 288 | + t.Error("a server with an unset variable and no default should not start") | |
| 289 | + } | |
| 290 | + if len(notes) != 1 || !strings.Contains(notes[0], "NOPE") { | |
| 291 | + t.Errorf("notes %q should name the missing variable", notes) | |
| 292 | + } | |
| 293 | + } | |
| @@ -0,0 +1,376 @@ | ||
| 1 | + package ccsettings | |
| 2 | + | |
| 3 | + // TEMPORARY MATCHER — swap at merge. | |
| 4 | + // | |
| 5 | + // This file is a deliberately small parser and matcher for Claude Code | |
| 6 | + // permission rules, written so the loading and honouring in this package could | |
| 7 | + // be tested end to end while the shared rule parser (ParseRule / Rule.Matches) | |
| 8 | + // was being written elsewhere. Everything outside this file talks to it | |
| 9 | + // through compile and the compiled interface, so replacing it means writing an | |
| 10 | + // adapter from the shared Rule to compiled and deleting this file. The | |
| 11 | + // tri-state result and the allow-versus-deny distinction for compound shell | |
| 12 | + // commands are the parts an adapter must keep; see Match and matchMode. | |
| 13 | + | |
| 14 | + import ( | |
| 15 | + "encoding/json" | |
| 16 | + "fmt" | |
| 17 | + "net/url" | |
| 18 | + "path/filepath" | |
| 19 | + "regexp" | |
| 20 | + "strings" | |
| 21 | + | |
| 22 | + "github.com/bmatcuk/doublestar/v4" | |
| 23 | + | |
| 24 | + "github.com/johannSo/codey/internal/approval" | |
| 25 | + ) | |
| 26 | + | |
| 27 | + // Match is the result of checking one rule against one call. | |
| 28 | + type Match int | |
| 29 | + | |
| 30 | + const ( | |
| 31 | + NoMatch Match = iota | |
| 32 | + Matched | |
| 33 | + // Unsure means the call cannot be pinned down well enough to say — an | |
| 34 | + // opaque shell command, a word built at run time. An allow rule treats it | |
| 35 | + // as no match; a deny or ask rule treats it as a reason to ask. | |
| 36 | + Unsure | |
| 37 | + ) | |
| 38 | + | |
| 39 | + // matchMode is which kind of rule is asking. It matters for compound shell | |
| 40 | + // commands: an allow rule covers "a && b" only if it covers both, while a | |
| 41 | + // deny rule catches it if it catches either. | |
| 42 | + type matchMode int | |
| 43 | + | |
| 44 | + const ( | |
| 45 | + forAllow matchMode = iota | |
| 46 | + forRestrict | |
| 47 | + ) | |
| 48 | + | |
| 49 | + // compiled is one parsed rule. | |
| 50 | + type compiled interface { | |
| 51 | + Match(tool string, input json.RawMessage, mode matchMode) Match | |
| 52 | + } | |
| 53 | + | |
| 54 | + // matchEnv is what relative rule paths are resolved against. | |
| 55 | + type matchEnv struct { | |
| 56 | + home, root string | |
| 57 | + } | |
| 58 | + | |
| 59 | + // codeyTools maps Claude Code's tool names to Codey's. Edit covers both of | |
| 60 | + // Codey's writing tools, as Claude Code's Edit rules cover all of its own; Read | |
| 61 | + // covers the search tools as well, on a best-effort basis, as it does there. | |
| 62 | + var codeyTools = map[string][]string{ | |
| 63 | + "Bash": {"bash"}, | |
| 64 | + "Read": {"read_file", "glob", "grep"}, | |
| 65 | + "Edit": {"edit_file", "write_file"}, | |
| 66 | + "MultiEdit": {"edit_file"}, | |
| 67 | + "Write": {"write_file"}, | |
| 68 | + "Glob": {"glob"}, | |
| 69 | + "Grep": {"grep"}, | |
| 70 | + "WebFetch": {"web_fetch"}, | |
| 71 | + "WebSearch": {"web_search"}, | |
| 72 | + "TodoWrite": {"todo_write"}, | |
| 73 | + } | |
| 74 | + | |
| 75 | + var ruleShape = regexp.MustCompile(`^([A-Za-z0-9_\-]+(?:__\*)?)(?:\((.*)\))?$`) | |
| 76 | + | |
| 77 | + // compile parses a rule. An error means the rule names something Codey has no | |
| 78 | + // equivalent for, or uses a specifier this matcher does not understand; the | |
| 79 | + // caller reports it and the rule takes no part. | |
| 80 | + func compile(rule string, env matchEnv) (compiled, error) { | |
| 81 | + m := ruleShape.FindStringSubmatch(strings.TrimSpace(rule)) | |
| 82 | + if m == nil { | |
| 83 | + return nil, fmt.Errorf("not a rule Codey can read") | |
| 84 | + } | |
| 85 | + name, spec := m[1], strings.TrimSpace(m[2]) | |
| 86 | + hasSpec := spec != "" && spec != "*" | |
| 87 | + | |
| 88 | + if strings.HasPrefix(name, "mcp__") { | |
| 89 | + if m[2] != "" { | |
| 90 | + return nil, fmt.Errorf("an MCP rule takes no specifier") | |
| 91 | + } | |
| 92 | + return mcpRule(name), nil | |
| 93 | + } | |
| 94 | + tools, ok := codeyTools[name] | |
| 95 | + if !ok { | |
| 96 | + return nil, fmt.Errorf("Codey has no %s tool", name) | |
| 97 | + } | |
| 98 | + if !hasSpec { | |
| 99 | + return toolRule(tools), nil | |
| 100 | + } | |
| 101 | + switch name { | |
| 102 | + case "Bash": | |
| 103 | + return newBashRule(spec), nil | |
| 104 | + case "Read", "Edit", "MultiEdit", "Write", "Glob", "Grep": | |
| 105 | + return newPathRule(tools, spec, env), nil | |
| 106 | + case "WebFetch": | |
| 107 | + if d, ok := strings.CutPrefix(spec, "domain:"); ok && d != "" { | |
| 108 | + return domainRule(strings.ToLower(d)), nil | |
| 109 | + } | |
| 110 | + } | |
| 111 | + return nil, fmt.Errorf("Codey does not understand the specifier %q", spec) | |
| 112 | + } | |
| 113 | + | |
| 114 | + // toolRule is a bare tool name: every call of it. | |
| 115 | + type toolRule []string | |
| 116 | + | |
| 117 | + func (r toolRule) Match(tool string, _ json.RawMessage, _ matchMode) Match { | |
| 118 | + for _, t := range r { | |
| 119 | + if t == tool { | |
| 120 | + return Matched | |
| 121 | + } | |
| 122 | + } | |
| 123 | + return NoMatch | |
| 124 | + } | |
| 125 | + | |
| 126 | + // mcpRule is mcp__server, mcp__server__* or mcp__server__tool. | |
| 127 | + type mcpRule string | |
| 128 | + | |
| 129 | + func (r mcpRule) Match(tool string, _ json.RawMessage, _ matchMode) Match { | |
| 130 | + name := string(r) | |
| 131 | + if server, ok := strings.CutSuffix(name, "__*"); ok { | |
| 132 | + name = server | |
| 133 | + } | |
| 134 | + if strings.Count(name, "__") == 1 { | |
| 135 | + // The whole server. | |
| 136 | + if strings.HasPrefix(tool, name+"__") { | |
| 137 | + return Matched | |
| 138 | + } | |
| 139 | + return NoMatch | |
| 140 | + } | |
| 141 | + if tool == name { | |
| 142 | + return Matched | |
| 143 | + } | |
| 144 | + return NoMatch | |
| 145 | + } | |
| 146 | + | |
| 147 | + // bashRule is Bash(spec): an exact command, a prefix written "cmd:*" or | |
| 148 | + // "cmd *", or a pattern with * in it. | |
| 149 | + type bashRule struct { | |
| 150 | + prefix []string // set for a prefix rule | |
| 151 | + exact []string // set for an exact rule | |
| 152 | + pattern *regexp.Regexp | |
| 153 | + } | |
| 154 | + | |
| 155 | + func newBashRule(spec string) bashRule { | |
| 156 | + if p, ok := strings.CutSuffix(spec, ":*"); ok { | |
| 157 | + return bashRule{prefix: strings.Fields(p)} | |
| 158 | + } | |
| 159 | + if p, ok := strings.CutSuffix(spec, " *"); ok && !strings.Contains(p, "*") { | |
| 160 | + return bashRule{prefix: strings.Fields(p)} | |
| 161 | + } | |
| 162 | + if strings.Contains(spec, "*") { | |
| 163 | + parts := strings.Split(strings.Join(strings.Fields(spec), " "), "*") | |
| 164 | + for i, p := range parts { | |
| 165 | + parts[i] = regexp.QuoteMeta(p) | |
| 166 | + } | |
| 167 | + return bashRule{pattern: regexp.MustCompile("^" + strings.Join(parts, ".*") + "$")} | |
| 168 | + } | |
| 169 | + return bashRule{exact: strings.Fields(spec)} | |
| 170 | + } | |
| 171 | + | |
| 172 | + func (r bashRule) Match(tool string, input json.RawMessage, mode matchMode) Match { | |
| 173 | + if tool != "bash" { | |
| 174 | + return NoMatch | |
| 175 | + } | |
| 176 | + var args struct { | |
| 177 | + Command string `json:"command"` | |
| 178 | + } | |
| 179 | + json.Unmarshal(input, &args) | |
| 180 | + cmds, info := approval.SimpleCommands(args.Command) | |
| 181 | + | |
| 182 | + if mode == forAllow { | |
| 183 | + // Allowing is all or nothing: every command in the line must be | |
| 184 | + // covered, and a line whose commands cannot all be seen covers | |
| 185 | + // nothing. | |
| 186 | + if info.Opaque || info.ParseError != nil || len(cmds) == 0 { | |
| 187 | + return NoMatch | |
| 188 | + } | |
| 189 | + for _, c := range cmds { | |
| 190 | + if !c.Pinned || r.one(c) != Matched { | |
| 191 | + return NoMatch | |
| 192 | + } | |
| 193 | + } | |
| 194 | + return Matched | |
| 195 | + } | |
| 196 | + | |
| 197 | + // Restricting needs one command to be caught. A line that cannot be seen | |
| 198 | + // all the way through might hide one, which is Unsure rather than a pass. | |
| 199 | + unsure := info.Opaque || info.ParseError != nil | |
| 200 | + for _, c := range cmds { | |
| 201 | + switch r.one(c) { | |
| 202 | + case Matched: | |
| 203 | + return Matched | |
| 204 | + case Unsure: | |
| 205 | + unsure = true | |
| 206 | + } | |
| 207 | + } | |
| 208 | + if unsure { | |
| 209 | + return Unsure | |
| 210 | + } | |
| 211 | + return NoMatch | |
| 212 | + } | |
| 213 | + | |
| 214 | + // one matches a single simple command. | |
| 215 | + func (r bashRule) one(c approval.SimpleCommand) Match { | |
| 216 | + switch { | |
| 217 | + case r.prefix != nil: | |
| 218 | + return wordsMatch(r.prefix, c, true) | |
| 219 | + case r.exact != nil: | |
| 220 | + return wordsMatch(r.exact, c, false) | |
| 221 | + default: | |
| 222 | + for _, lit := range c.Literal { | |
| 223 | + if !lit { | |
| 224 | + return Unsure | |
| 225 | + } | |
| 226 | + } | |
| 227 | + if r.pattern.MatchString(strings.Join(c.Words, " ")) { | |
| 228 | + return Matched | |
| 229 | + } | |
| 230 | + return NoMatch | |
| 231 | + } | |
| 232 | + } | |
| 233 | + | |
| 234 | + // wordsMatch compares a rule's words to a command's. A word the shell builds | |
| 235 | + // at run time could be anything, so meeting one before the comparison is | |
| 236 | + // settled is Unsure. | |
| 237 | + func wordsMatch(want []string, c approval.SimpleCommand, prefix bool) Match { | |
| 238 | + if len(c.Words) < len(want) || (!prefix && len(c.Words) != len(want)) { | |
| 239 | + return NoMatch | |
| 240 | + } | |
| 241 | + for i, w := range want { | |
| 242 | + if !c.Literal[i] { | |
| 243 | + return Unsure | |
| 244 | + } | |
| 245 | + if c.Words[i] != w { | |
| 246 | + return NoMatch | |
| 247 | + } | |
| 248 | + } | |
| 249 | + return Matched | |
| 250 | + } | |
| 251 | + | |
| 252 | + // pathRule is Read(spec), Edit(spec) and the like: a gitignore-style pattern | |
| 253 | + // over the path a tool is handed. | |
| 254 | + type pathRule struct { | |
| 255 | + tools []string | |
| 256 | + pattern string // absolute, slash-separated, doublestar syntax | |
| 257 | + root string | |
| 258 | + } | |
| 259 | + | |
| 260 | + // newPathRule resolves a pattern the way Claude Code documents it: | |
| 261 | + // //path is absolute, ~/path is under the home directory, /path is under | |
| 262 | + // the project root, and anything else is relative to the working directory — | |
| 263 | + // which for Codey is the root. A pattern with no slash in it matches at any | |
| 264 | + // depth, as it would in .gitignore. | |
| 265 | + func newPathRule(tools []string, spec string, env matchEnv) pathRule { | |
| 266 | + spec = strings.TrimSuffix(spec, "/") | |
| 267 | + var p string | |
| 268 | + switch { | |
| 269 | + case strings.HasPrefix(spec, "//"): | |
| 270 | + p = spec[1:] | |
| 271 | + case strings.HasPrefix(spec, "~/"): | |
| 272 | + p = escape(filepath.ToSlash(env.home)) + spec[1:] | |
| 273 | + case strings.HasPrefix(spec, "/"): | |
| 274 | + p = escape(filepath.ToSlash(env.root)) + spec | |
| 275 | + default: | |
| 276 | + rel := strings.TrimPrefix(spec, "./") | |
| 277 | + if !strings.Contains(rel, "/") && !strings.HasPrefix(spec, "./") { | |
| 278 | + rel = "**/" + rel | |
| 279 | + } | |
| 280 | + p = escape(filepath.ToSlash(env.root)) + "/" + rel | |
| 281 | + } | |
| 282 | + return pathRule{tools: tools, pattern: p, root: env.root} | |
| 283 | + } | |
| 284 | + | |
| 285 | + func (r pathRule) Match(tool string, input json.RawMessage, mode matchMode) Match { | |
| 286 | + if toolRule(r.tools).Match(tool, nil, mode) != Matched { | |
| 287 | + return NoMatch | |
| 288 | + } | |
| 289 | + var args struct { | |
| 290 | + Path string `json:"path"` | |
| 291 | + } | |
| 292 | + json.Unmarshal(input, &args) | |
| 293 | + path := args.Path | |
| 294 | + if path == "" { | |
| 295 | + path = r.root | |
| 296 | + } | |
| 297 | + if !filepath.IsAbs(path) { | |
| 298 | + path = filepath.Join(r.root, path) | |
| 299 | + } | |
| 300 | + candidates := []string{filepath.Clean(path)} | |
| 301 | + // The tools follow symlinks, so a rule about .env has to catch a link | |
| 302 | + // to it under another name. | |
| 303 | + if real, err := filepath.EvalSymlinks(path); err == nil && real != candidates[0] { | |
| 304 | + candidates = append(candidates, real) | |
| 305 | + } | |
| 306 | + hits := 0 | |
| 307 | + for _, c := range candidates { | |
| 308 | + if r.covers(filepath.ToSlash(c)) { | |
| 309 | + hits++ | |
| 310 | + } | |
| 311 | + } | |
| 312 | + switch { | |
| 313 | + case mode == forAllow && hits == len(candidates): | |
| 314 | + return Matched | |
| 315 | + case mode == forRestrict && hits > 0: | |
| 316 | + return Matched | |
| 317 | + } | |
| 318 | + return NoMatch | |
| 319 | + } | |
| 320 | + | |
| 321 | + // covers reports whether the pattern matches the path or any directory above | |
| 322 | + // it: a rule about a directory, as in .gitignore, is a rule about everything | |
| 323 | + // inside it. | |
| 324 | + func (r pathRule) covers(path string) bool { | |
| 325 | + for p := path; ; { | |
| 326 | + if ok, _ := doublestar.Match(r.pattern, p); ok { | |
| 327 | + return true | |
| 328 | + } | |
| 329 | + parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(p))) | |
| 330 | + if parent == p { | |
| 331 | + return false | |
| 332 | + } | |
| 333 | + p = parent | |
| 334 | + } | |
| 335 | + } | |
| 336 | + | |
| 337 | + func escape(s string) string { | |
| 338 | + var b strings.Builder | |
| 339 | + for _, r := range s { | |
| 340 | + if strings.ContainsRune(`*?[]{}\`, r) { | |
| 341 | + b.WriteByte('\\') | |
| 342 | + } | |
| 343 | + b.WriteRune(r) | |
| 344 | + } | |
| 345 | + return b.String() | |
| 346 | + } | |
| 347 | + | |
| 348 | + // domainRule is WebFetch(domain:example.com). *.example.com covers the | |
| 349 | + // subdomains; the bare name covers only itself. | |
| 350 | + type domainRule string | |
| 351 | + | |
| 352 | + func (r domainRule) Match(tool string, input json.RawMessage, _ matchMode) Match { | |
| 353 | + if tool != "web_fetch" { | |
| 354 | + return NoMatch | |
| 355 | + } | |
| 356 | + var args struct { | |
| 357 | + URL string `json:"url"` | |
| 358 | + } | |
| 359 | + json.Unmarshal(input, &args) | |
| 360 | + u, err := url.Parse(args.URL) | |
| 361 | + if err != nil || u.Hostname() == "" { | |
| 362 | + return Unsure | |
| 363 | + } | |
| 364 | + host := strings.ToLower(u.Hostname()) | |
| 365 | + d := string(r) | |
| 366 | + if sub, ok := strings.CutPrefix(d, "*."); ok { | |
| 367 | + if strings.HasSuffix(host, "."+sub) { | |
| 368 | + return Matched | |
| 369 | + } | |
| 370 | + return NoMatch | |
| 371 | + } | |
| 372 | + if host == d { | |
| 373 | + return Matched | |
| 374 | + } | |
| 375 | + return NoMatch | |
| 376 | + } | |
| @@ -0,0 +1,249 @@ | ||
| 1 | + package ccsettings | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "fmt" | |
| 5 | + "path/filepath" | |
| 6 | + "regexp" | |
| 7 | + "sort" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "github.com/johannSo/codey/internal/config" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + // ServerDef is one MCP server as Claude Code writes it, before any ${VAR} in | |
| 14 | + // it is expanded. It is kept unexpanded because that is the form the trust | |
| 15 | + // decision is about: what the repository wrote, not what this shell happened | |
| 16 | + // to substitute into it. | |
| 17 | + type ServerDef struct { | |
| 18 | + // Type is "stdio", or empty, which means the same thing. "http", "sse" | |
| 19 | + // and "ws" are remote transports Codey's client does not speak. | |
| 20 | + Type string `json:"type,omitempty"` | |
| 21 | + Command string `json:"command,omitempty"` | |
| 22 | + Args []string `json:"args,omitempty"` | |
| 23 | + Env map[string]string `json:"env,omitempty"` | |
| 24 | + URL string `json:"url,omitempty"` | |
| 25 | + } | |
| 26 | + | |
| 27 | + // stdio reports whether Codey can start this server. A url is a remote | |
| 28 | + // server whatever the type says. | |
| 29 | + func (d ServerDef) stdio() bool { | |
| 30 | + return (d.Type == "" || d.Type == "stdio") && d.URL == "" | |
| 31 | + } | |
| 32 | + | |
| 33 | + // MCPFile is the servers one Claude Code file defines, the remote ones | |
| 34 | + // already removed. | |
| 35 | + type MCPFile struct { | |
| 36 | + Scope Scope | |
| 37 | + // Shown names the file in messages; for ~/.claude.json's per-project | |
| 38 | + // servers it says which project. | |
| 39 | + Shown string | |
| 40 | + Servers map[string]ServerDef | |
| 41 | + } | |
| 42 | + | |
| 43 | + // mcpJSON is the shape of .mcp.json, and of the parts of ~/.claude.json read | |
| 44 | + // here. ~/.claude.json carries a great deal more — login state, tips shown, | |
| 45 | + // per-project history — none of which is any of Codey's business. | |
| 46 | + type mcpJSON struct { | |
| 47 | + MCPServers map[string]ServerDef `json:"mcpServers"` | |
| 48 | + Projects map[string]struct { | |
| 49 | + MCPServers map[string]ServerDef `json:"mcpServers"` | |
| 50 | + } `json:"projects"` | |
| 51 | + } | |
| 52 | + | |
| 53 | + // LoadMCP reads the MCP servers Claude Code would offer in root, from the | |
| 54 | + // three places it keeps them, most specific first: | |
| 55 | + // | |
| 56 | + // - ~/.claude.json, under projects[root] — "local" scope, the user's own | |
| 57 | + // servers for this one project, which is what `claude mcp add` writes; | |
| 58 | + // - .mcp.json at the root — "project" scope, committed to the repository; | |
| 59 | + // - ~/.claude.json, top-level — "user" scope, for every project. | |
| 60 | + // | |
| 61 | + // That is Claude Code's own order of precedence, and the order MergeMCP | |
| 62 | + // resolves name clashes in. Servers Codey cannot start — a url, or a type | |
| 63 | + // other than stdio — are reported and left out here, so nothing downstream | |
| 64 | + // has to know they existed. | |
| 65 | + func LoadMCP(home, root string) ([]MCPFile, []error) { | |
| 66 | + var errs []error | |
| 67 | + var claude mcpJSON | |
| 68 | + claudePath := "" | |
| 69 | + if home != "" { | |
| 70 | + claudePath = filepath.Join(home, ClaudeJSON) | |
| 71 | + if _, err := readJSON(claudePath, &claude); err != nil { | |
| 72 | + errs = append(errs, fmt.Errorf("claude code mcp: %w", err)) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + var files []MCPFile | |
| 77 | + add := func(scope Scope, label string, defs map[string]ServerDef) { | |
| 78 | + if len(defs) == 0 { | |
| 79 | + return | |
| 80 | + } | |
| 81 | + kept := map[string]ServerDef{} | |
| 82 | + for _, name := range sortedNames(defs) { | |
| 83 | + d := defs[name] | |
| 84 | + if !d.stdio() { | |
| 85 | + transport := d.Type | |
| 86 | + if transport == "" || transport == "stdio" { | |
| 87 | + transport = "url" | |
| 88 | + } | |
| 89 | + errs = append(errs, fmt.Errorf("mcp: %s in %s is a remote server (%s), and Codey's client speaks only stdio — skipped", name, label, transport)) | |
| 90 | + continue | |
| 91 | + } | |
| 92 | + kept[name] = d | |
| 93 | + } | |
| 94 | + if len(kept) > 0 { | |
| 95 | + files = append(files, MCPFile{Scope: scope, Shown: label, Servers: kept}) | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + if claudePath != "" && root != "" { | |
| 100 | + // Claude Code keys a project by the directory it was started in. A | |
| 101 | + // root reached through a symlink is the same project under another | |
| 102 | + // name, so both spellings are tried. | |
| 103 | + for _, key := range rootKeys(root) { | |
| 104 | + if p, ok := claude.Projects[key]; ok { | |
| 105 | + add(ScopeLocal, "~/"+ClaudeJSON+" (this project)", p.MCPServers) | |
| 106 | + break | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + if root != "" { | |
| 111 | + var project mcpJSON | |
| 112 | + path := filepath.Join(root, ProjectMCP) | |
| 113 | + if _, err := readJSON(path, &project); err != nil { | |
| 114 | + errs = append(errs, fmt.Errorf("claude code mcp: %w", err)) | |
| 115 | + } else { | |
| 116 | + add(ScopeProject, ProjectMCP, project.MCPServers) | |
| 117 | + } | |
| 118 | + } | |
| 119 | + if claudePath != "" { | |
| 120 | + add(ScopeUser, "~/"+ClaudeJSON, claude.MCPServers) | |
| 121 | + } | |
| 122 | + return files, errs | |
| 123 | + } | |
| 124 | + | |
| 125 | + func rootKeys(root string) []string { | |
| 126 | + keys := []string{filepath.Clean(root)} | |
| 127 | + if real, err := filepath.EvalSymlinks(root); err == nil && real != keys[0] { | |
| 128 | + keys = append(keys, real) | |
| 129 | + } | |
| 130 | + return keys | |
| 131 | + } | |
| 132 | + | |
| 133 | + // Project is the project-scope file in files, or nil. | |
| 134 | + func Project(files []MCPFile) *MCPFile { | |
| 135 | + for i := range files { | |
| 136 | + if files[i].Scope == ScopeProject { | |
| 137 | + return &files[i] | |
| 138 | + } | |
| 139 | + } | |
| 140 | + return nil | |
| 141 | + } | |
| 142 | + | |
| 143 | + // MergeMCP combines Codey's own servers with Claude Code's into the set to | |
| 144 | + // start, resolving each name to one definition and expanding ${VAR} in it. | |
| 145 | + // | |
| 146 | + // Codey's own config wins a clash, because it is the file written for this | |
| 147 | + // program; after it, files are taken in the order LoadMCP returned them. Each | |
| 148 | + // clash is reported once, naming the winner, because a server that silently | |
| 149 | + // turned out to be a different program is the kind of surprise nobody thinks | |
| 150 | + // to debug. | |
| 151 | + // | |
| 152 | + // trusted says whether the project's .mcp.json may be started. When it may | |
| 153 | + // not, its servers are dropped before anything else is decided — so a user's | |
| 154 | + // own server with the same name is the one that runs, rather than nothing. | |
| 155 | + // | |
| 156 | + // lookup is the environment ${VAR} is expanded from: os.LookupEnv in | |
| 157 | + // production, a map in the tests. | |
| 158 | + func MergeMCP(own map[string]config.MCPServer, files []MCPFile, trusted bool, lookup func(string) (string, bool)) (map[string]config.MCPServer, []string) { | |
| 159 | + out := map[string]config.MCPServer{} | |
| 160 | + from := map[string]string{} | |
| 161 | + for name, s := range own { | |
| 162 | + out[name] = s | |
| 163 | + from[name] = "Codey's config" | |
| 164 | + } | |
| 165 | + var notes []string | |
| 166 | + for _, f := range files { | |
| 167 | + if f.Scope == ScopeProject && !trusted { | |
| 168 | + continue | |
| 169 | + } | |
| 170 | + for _, name := range sortedNames(f.Servers) { | |
| 171 | + if winner, taken := from[name]; taken { | |
| 172 | + notes = append(notes, fmt.Sprintf("mcp: %s is defined in both %s and %s — using the one in %s", name, winner, f.Shown, winner)) | |
| 173 | + continue | |
| 174 | + } | |
| 175 | + spec, err := expandServer(f.Servers[name], lookup) | |
| 176 | + if err != nil { | |
| 177 | + notes = append(notes, fmt.Sprintf("mcp: %s in %s: %v — skipped", name, f.Shown, err)) | |
| 178 | + // Claimed all the same: a broken definition in a more specific | |
| 179 | + // file should not quietly hand the name to a less specific one. | |
| 180 | + from[name] = f.Shown | |
| 181 | + continue | |
| 182 | + } | |
| 183 | + out[name] = spec | |
| 184 | + from[name] = f.Shown | |
| 185 | + } | |
| 186 | + } | |
| 187 | + return out, notes | |
| 188 | + } | |
| 189 | + | |
| 190 | + // envRef is ${VAR} or ${VAR:-default}, the two forms Claude Code expands in a | |
| 191 | + // server definition. | |
| 192 | + var envRef = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`) | |
| 193 | + | |
| 194 | + // expand substitutes every ${VAR} in s. A variable that is unset, or set to | |
| 195 | + // nothing, takes its default when it has one — the shell's reading of :- — | |
| 196 | + // and is an error when it has none: Claude Code refuses such a definition | |
| 197 | + // rather than starting a server with a hole in its command line, and so does | |
| 198 | + // this. | |
| 199 | + func expand(s string, lookup func(string) (string, bool)) (string, error) { | |
| 200 | + var missing []string | |
| 201 | + out := envRef.ReplaceAllStringFunc(s, func(ref string) string { | |
| 202 | + m := envRef.FindStringSubmatch(ref) | |
| 203 | + if v, ok := lookup(m[1]); ok && v != "" { | |
| 204 | + return v | |
| 205 | + } | |
| 206 | + if m[2] != "" { | |
| 207 | + return m[3] | |
| 208 | + } | |
| 209 | + missing = append(missing, m[1]) | |
| 210 | + return "" | |
| 211 | + }) | |
| 212 | + if len(missing) > 0 { | |
| 213 | + return "", fmt.Errorf("%s is not set and has no default", strings.Join(missing, ", ")) | |
| 214 | + } | |
| 215 | + return out, nil | |
| 216 | + } | |
| 217 | + | |
| 218 | + func expandServer(d ServerDef, lookup func(string) (string, bool)) (config.MCPServer, error) { | |
| 219 | + var err error | |
| 220 | + s := config.MCPServer{} | |
| 221 | + if s.Command, err = expand(d.Command, lookup); err != nil { | |
| 222 | + return s, err | |
| 223 | + } | |
| 224 | + for _, a := range d.Args { | |
| 225 | + v, err := expand(a, lookup) | |
| 226 | + if err != nil { | |
| 227 | + return s, err | |
| 228 | + } | |
| 229 | + s.Args = append(s.Args, v) | |
| 230 | + } | |
| 231 | + if len(d.Env) > 0 { | |
| 232 | + s.Env = map[string]string{} | |
| 233 | + for k, v := range d.Env { | |
| 234 | + if s.Env[k], err = expand(v, lookup); err != nil { | |
| 235 | + return s, err | |
| 236 | + } | |
| 237 | + } | |
| 238 | + } | |
| 239 | + return s, nil | |
| 240 | + } | |
| 241 | + | |
| 242 | + func sortedNames[V any](m map[string]V) []string { | |
| 243 | + names := make([]string, 0, len(m)) | |
| 244 | + for n := range m { | |
| 245 | + names = append(names, n) | |
| 246 | + } | |
| 247 | + sort.Strings(names) | |
| 248 | + return names | |
| 249 | + } | |
| @@ -0,0 +1,106 @@ | ||
| 1 | + package ccsettings | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "os/exec" | |
| 7 | + "path/filepath" | |
| 8 | + "time" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + // Permissions is the permissions block of one Claude Code settings file, as | |
| 12 | + // written. Which of it is honoured is decided by NewRuleset, not here. | |
| 13 | + type Permissions struct { | |
| 14 | + Scope Scope | |
| 15 | + // Path is the file, and Shown is how a message names it. | |
| 16 | + Path string | |
| 17 | + Shown string | |
| 18 | + Allow []string | |
| 19 | + Deny []string | |
| 20 | + Ask []string | |
| 21 | + // DefaultMode is Claude Code's starting permission mode. Only | |
| 22 | + // "acceptEdits" and "default" mean anything to Codey, and only from a | |
| 23 | + // file the user wrote. | |
| 24 | + DefaultMode string | |
| 25 | + // Tracked is set on a settings.local.json that git is tracking. Claude | |
| 26 | + // Code keeps that file out of git, so one that is committed came from the | |
| 27 | + // repository like the shared file did, and is treated as project scope. | |
| 28 | + Tracked bool | |
| 29 | + } | |
| 30 | + | |
| 31 | + // settingsFile is the part of a settings file this package reads. Hooks, env, | |
| 32 | + // model, statusLine and the rest are other harnesses' business; see the | |
| 33 | + // README for what is deliberately not carried over. | |
| 34 | + type settingsFile struct { | |
| 35 | + Permissions struct { | |
| 36 | + Allow []string `json:"allow"` | |
| 37 | + Deny []string `json:"deny"` | |
| 38 | + Ask []string `json:"ask"` | |
| 39 | + DefaultMode string `json:"defaultMode"` | |
| 40 | + } `json:"permissions"` | |
| 41 | + } | |
| 42 | + | |
| 43 | + // LoadPermissions reads the three settings files Claude Code would read for | |
| 44 | + // root, in the order it would read them: the user's, the project's shared | |
| 45 | + // file, and the project's local one. A file that is not there is skipped | |
| 46 | + // silently; one that cannot be parsed is reported and skipped, which leaves | |
| 47 | + // its deny rules unenforced — so the error is worth putting in front of the | |
| 48 | + // user rather than in a log. | |
| 49 | + // | |
| 50 | + // home may be empty, for a machine with no home directory; the user's file is | |
| 51 | + // then not read. | |
| 52 | + func LoadPermissions(home, root string) ([]Permissions, []error) { | |
| 53 | + type candidate struct { | |
| 54 | + scope Scope | |
| 55 | + path string | |
| 56 | + } | |
| 57 | + var files []candidate | |
| 58 | + if home != "" { | |
| 59 | + files = append(files, candidate{ScopeUser, filepath.Join(home, UserSettings)}) | |
| 60 | + } | |
| 61 | + // A root that is the home directory would read the user's file twice, | |
| 62 | + // once as a project file; it is the user's. | |
| 63 | + if root != "" && filepath.Clean(root) != filepath.Clean(home) { | |
| 64 | + files = append(files, | |
| 65 | + candidate{ScopeProject, filepath.Join(root, ProjectSettings)}, | |
| 66 | + candidate{ScopeLocal, filepath.Join(root, LocalSettings)}) | |
| 67 | + } | |
| 68 | + | |
| 69 | + var out []Permissions | |
| 70 | + var errs []error | |
| 71 | + for _, f := range files { | |
| 72 | + var s settingsFile | |
| 73 | + ok, err := readJSON(f.path, &s) | |
| 74 | + if err != nil { | |
| 75 | + errs = append(errs, fmt.Errorf("claude code settings: %w", err)) | |
| 76 | + continue | |
| 77 | + } | |
| 78 | + if !ok { | |
| 79 | + continue | |
| 80 | + } | |
| 81 | + p := Permissions{ | |
| 82 | + Scope: f.scope, | |
| 83 | + Path: f.path, | |
| 84 | + Shown: shown(home, root, f.path), | |
| 85 | + Allow: s.Permissions.Allow, | |
| 86 | + Deny: s.Permissions.Deny, | |
| 87 | + Ask: s.Permissions.Ask, | |
| 88 | + DefaultMode: s.Permissions.DefaultMode, | |
| 89 | + } | |
| 90 | + if f.scope == ScopeLocal && gitTracks(root, LocalSettings) { | |
| 91 | + p.Scope, p.Tracked = ScopeProject, true | |
| 92 | + } | |
| 93 | + out = append(out, p) | |
| 94 | + } | |
| 95 | + return out, errs | |
| 96 | + } | |
| 97 | + | |
| 98 | + // gitTracks reports whether git has rel in its index under root. Any failure | |
| 99 | + // — no git, not a repository — reads as untracked, which is the honest answer | |
| 100 | + // for a directory git knows nothing about: nobody committed the file. | |
| 101 | + func gitTracks(root, rel string) bool { | |
| 102 | + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 103 | + defer cancel() | |
| 104 | + cmd := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "--error-unmatch", "--", rel) | |
| 105 | + return cmd.Run() == nil | |
| 106 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.