Vectrum
Sign In

Merge claude CLI flags: -p in claude's output formats, and its permission rules

One rule matcher for both: .claude/settings.json is read with
approval.ParseRule, the same as --allowedTools. RuleList is a RuleSet, and
Combine decides the settings files' and the command line's together. The
shared matcher now covers a directory's contents, follows symlinks, and
says when a deny rule could not see into a command.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Jjonicommitted 2 parents bc84208 44ef1bccommit686688a

Showing 18 changed files with 3247 additions and 535 deletions.

Merge commit — showing changes against the first parent.

+142−3README.mdView File
@@ -120,7 +120,13 @@ Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`,
120120 `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`,
121121 `-no-tui`, `-no-skills`, `-no-agents`, `-no-web`, `-no-hooks`,
122122 `-no-diagnostics`, `-no-mcp`, `-claude-code-auth`, `-no-claude-code-auth`,
123- `-trust-project`.
123+ `-trust-project`. And the ones a script written for Claude Code passes, with
124+ Claude Code's meaning — see [using it in place of
125+ `claude`](#using-it-in-place-of-claude): `--output-format
126+ text|json|stream-json`, `-c`/`--continue`, `--allowedTools`,
127+ `--disallowedTools`, `--permission-mode`, `--dangerously-skip-permissions`,
128+ `--append-system-prompt`, `--system-prompt`, `--verbose`, `--print`. Every flag
129+ takes one dash or two.
124130
125131 ### Flags and thresholds
126132
@@ -1617,6 +1623,135 @@ the only one where pressing enter through the screens does something: the row
16171623 names the exact path before you answer, and the notice afterwards names the
16181624 file that was written. Uninstalling is `rm -r ~/.agents/skills/herdr`.
16191625
1626+ ## Using it in place of `claude`
1627+
1628+ A lot of CI jobs and shell scripts run an agent as `claude -p`. Codey reads the
1629+ same flags with the same meaning, so pointing one of them at Codey is meant to
1630+ be a matter of changing the binary's name:
1631+
1632+ ```bash
1633+ codey -p --output-format json "why does TestFoo fail?" | jq -r .result
1634+ echo "summarise the last commit" | codey -p --allowedTools "Bash(git log:*)" "Bash(git show:*)"
1635+ codey -c -p "now fix it" # same session
1636+ ```
1637+
1638+ | Claude Code flag | In Codey |
1639+ | --- | --- |
1640+ | `-p`, `--print` | One prompt, then exit. The prompt may follow `-p` — Codey's own spelling — or be a positional argument anywhere on the line, as `claude` takes it, or arrive on stdin when there is no other |
1641+ | `--output-format text` | The final answer and nothing else on stdout |
1642+ | `--output-format json` | One result object at the end |
1643+ | `--output-format stream-json` | One JSON line per message as the run goes, then the result object |
1644+ | `--verbose` | The same as `-v`. With `json` it prints every line `stream-json` would have, as one array, which is what Claude Code does; `stream-json` does not need it |
1645+ | `-c`, `--continue` | `-resume latest`: the most recent session in this working root |
1646+ | `--resume <id>` | Unchanged; the id is a Codey session id, which is what `session_id` in the output holds |
1647+ | `--model`, `--max-turns` | Codey's own flags, already spelled the same |
1648+ | `--allowedTools`, `--allowed-tools` | Calls to approve without asking, as rules |
1649+ | `--disallowedTools`, `--disallowed-tools` | Tools to take away, or calls to refuse, as rules |
1650+ | `--permission-mode` | `default`; `acceptEdits`, which lets edits and writes through and still asks before a command — the mode the ACP front end has by the same name; `bypassPermissions`, which is `-yolo`; `dontAsk`, which refuses whatever no rule allows |
1651+ | `--dangerously-skip-permissions` | `-yolo` |
1652+ | `--append-system-prompt`, `--system-prompt` | Add to the end of Codey's system prompt, or replace it. The session's own agent only: a subagent's prompt is its preset's |
1653+ | `--include-partial-messages` | Accepted, with a warning, and ignored |
1654+
1655+ Without `--output-format`, `-p` prints what it always has: the transcript as it
1656+ streams, the tool calls, a usage line — for a person watching, not a parser.
1657+ Asking for a format is asking for Claude Code's behaviour, and then stdout holds
1658+ the format and nothing else; notices, warnings and any approval prompt go to
1659+ stderr.
1660+
1661+ The shapes are Claude Code's, because the programs reading them were written
1662+ against it. The result object carries `type`, `subtype` (`success`,
1663+ `error_max_turns` or `error_during_execution`), `is_error`, `result`,
1664+ `session_id`, `num_turns`, `duration_ms`, `duration_api_ms`, `total_cost_usd`
1665+ and `usage` with the four token counts. In `stream-json` it is preceded by a
1666+ `system`/`init` line — working directory, model, tools, MCP servers, permission
1667+ mode — then an `assistant` line per response, holding the message as the
1668+ Messages API returned it, and a `user` line per batch of tool results. Numbers
1669+ are this invocation's: a resumed session reports what the resume cost, not
1670+ everything before it.
1671+
1672+ A run that fails exits 1, whatever the format, and the JSON formats still print
1673+ their result object — with `is_error: true`, the error in `errors`, and in
1674+ `result` whatever the model last said, or the error itself when it said
1675+ nothing. That includes failing before the model was ever asked: no credentials
1676+ gives a result object, with an empty `session_id` because there was no session
1677+ yet. Check `is_error` rather than `subtype`: it is the field that means
1678+ failure in both, where the subtypes for a failure are not guaranteed to agree
1679+ between the two. A flag
1680+ Codey does not know stops the run before any of this, with exit 2, and so does
1681+ a value it cannot use — `--permission-mode plan`, say — with exit 1 and a
1682+ message on stderr.
1683+
1684+ **Who gets asked.** Claude Code never prompts in `-p` mode, and with
1685+ `--output-format` neither does Codey when stdin is not a terminal: what no rule
1686+ allows is refused, and the model is told so and carries on. That is the case in
1687+ CI. At a terminal the prompt still comes up, on stderr. Codey's own `-p`
1688+ without a format keeps reading answers from stdin, so `yes | codey -p ...`
1689+ works as it always has — except when the prompt itself came from stdin, which
1690+ leaves nothing there to answer with.
1691+
1692+ **Rules.** `--allowedTools` and `--disallowedTools` take Claude Code's rule
1693+ syntax, comma- or space-separated, as many as you like after one flag:
1694+ `Bash`, `Bash(git status)`, `Bash(npm run test:*)`, `Read`, `Read(./src/**)`,
1695+ `Edit(./docs/**)`, `Write`, `Glob`, `Grep`, `WebFetch(domain:example.com)`,
1696+ `WebSearch`, `TodoWrite`, `Task`, `mcp__github`, `mcp__github__create_issue`.
1697+ Claude Code's names map onto Codey's tools — `Bash` is `bash`, `Read`
1698+ `read_file`, `Write` `write_file`, and `Edit` both `edit_file` and
1699+ `write_file`, because Claude Code applies its Edit rules to every tool that
1700+ edits files. Codey's own names work as well. A rule naming a tool Codey does
1701+ not have, `NotebookEdit` say, is a warning, and the rest of the list still
1702+ applies.
1703+
1704+ A rule for a whole tool on the disallowed list takes the tool away: the model
1705+ is never shown it. A narrower one — `Bash(git push:*)`, `Read(./.env)` — is
1706+ checked at approval time, before anything else, `-yolo` included, and a call it
1707+ matches is refused. On the allowed list a rule stands in for the "yes" you
1708+ would otherwise have been asked for.
1709+
1710+ `Bash(prefix:*)` matches word by word, so `Bash(ls:*)` covers `ls -la` and not
1711+ `lsof`. A compound command is allowed only when every simple command in it is
1712+ covered, so `git status; rm -rf ~` does not ride in on `Bash(git status:*)`, and
1713+ anything the [shell analysis](#safety) cannot see through — command
1714+ substitution, a variable in command position, an inline `bash -c` script, an
1715+ assignment like `PATH=...`, output redirected into a file — is never allowed by
1716+ a narrower rule than `Bash`. A deny rule is the other way round: one matching
1717+ part is enough, and it looks inside `bash -c` and `eval`, past `sudo`, `env`
1718+ and `timeout`, and at `/bin/rm` as `rm`. That catches the command a model
1719+ would ordinarily write. It is not a sandbox, for the reason the Safety section
1720+ gives.
1721+
1722+ Path rules read like gitignore, as Claude Code's do: `./` and `/` are the
1723+ working root, `//` an absolute path, `~/` your home, and a pattern with no
1724+ slash in it — `.env`, `*.pem` — matches a name at any depth. Path rules on
1725+ `Read` apply to `glob` and `grep` too, as far as their `path` argument goes.
1726+
1727+ What does not work:
1728+
1729+ - `--permission-mode plan`. Codey has no read-only planning mode, and says so
1730+ rather than run a mode it does not have. Ask for a plan in the prompt, and
1731+ `--disallowedTools Edit,Write,Bash` holds the run to it.
1732+ - `--input-format stream-json`, and so a conversation fed in over stdin. The
1733+ prompt is text.
1734+ - `--include-partial-messages`: there are no `stream_event` lines, only whole
1735+ messages.
1736+ - `--mcp-config` and `--strict-mcp-config`. MCP servers come from
1737+ [Codey's config](#mcp-servers).
1738+ - `--settings`, `--setting-sources`, `--add-dir`, `--fallback-model`,
1739+ `--session-id`, `--fork-session`, and `--resume` with no id, which in Claude
1740+ Code opens a picker.
1741+ - Piping a file in alongside a prompt. `cat log | claude -p "explain"` hands
1742+ Claude Code both; Codey reads stdin only when there is no prompt otherwise,
1743+ because a CI runner can leave stdin as a pipe nobody ever closes, and a run
1744+ that waited on it would hang there for good. Put it in the prompt:
1745+ `codey -p "explain: $(cat log)"`.
1746+ - A prompt without `-p`, which in Claude Code starts an interactive session
1747+ with that prompt. Codey ignores it and says so.
1748+ - Tool names in the output are Codey's — `bash`, `read_file` — not Claude
1749+ Code's. A script that looks for `"name":"Bash"` in the stream needs to look
1750+ for `bash`.
1751+ - The result object has no `permission_denials` or `modelUsage`, and lines
1752+ carry no `parent_tool_use_id` other than `null`: subagents report through a
1753+ tool result rather than in the parent's stream.
1754+
16201755 ## Inside another editor
16211756
16221757 Codey can be the agent behind somebody else's UI. It speaks the
@@ -1710,7 +1845,11 @@ a lot.
17101845
17111846 **Approval prompts.** Read-only tools run unattended. `bash`, `edit_file`,
17121847 `write_file`, `web_search` and `web_fetch` ask, with "yes / no / always this exact one / always this tool" and
1713- session memory. `/allowed` lists what you have agreed to.
1848+ session memory. `/allowed` lists what you have agreed to. `--allowedTools` and
1849+ `--disallowedTools` write the same decisions down ahead of time, in Claude
1850+ Code's rule syntax — see [using it in place of
1851+ `claude`](#using-it-in-place-of-claude) — and a deny rule there is checked
1852+ before everything else, `-yolo` included.
17141853
17151854 Shell commands are parsed with `mvdan.cc/sh/syntax` so the prompt can tell you
17161855 what a command will actually run — `git status; rm -rf ~` is shown as running
@@ -1778,7 +1917,7 @@ internal/skills discovery and parsing of SKILL.md, Codey's own and Claude Code
17781917 internal/websearch the search command, the URL jail, HTML to text
17791918 internal/mcp MCP client: starts the configured servers, offers their tools
17801919 internal/config saved defaults: model, effort, approvals, web search, theme, diagnostics
1781- internal/approval allow / deny / ask, with shell analysis
1920+ internal/approval allow / deny / ask, with shell analysis and Claude Code's permission rules
17821921 internal/ccsettings Claude Code's MCP servers, permission rules and CLAUDE.md, and who may grant what
17831922 internal/ask questions the agent puts to you mid-task, and the tool for it
17841923 internal/hooks the lifecycle, published: fan-out, and the config's commands
+338−0cmd/codey/claudecli.goaddedView File
@@ -0,0 +1,338 @@
1+ package main
2+
3+ // The command line a script written for Claude Code's `claude` sends.
4+ //
5+ // `claude -p` is how a lot of CI jobs and shell scripts run an agent, and the
6+ // point of this file is that pointing one of them at Codey instead should be a
7+ // matter of changing the binary's name. So the flags those scripts pass are
8+ // read here, in Claude Code's spelling and with Claude Code's meaning, and
9+ // turned into the settings Codey already had: --dangerously-skip-permissions
10+ // is -yolo, --continue is -resume latest, --allowedTools is a list of allow
11+ // rules on the approval policy.
12+ //
13+ // Two of them do not fit Go's flag package as they are. `claude -p` is a
14+ // switch and the prompt is a positional argument — `claude -p --output-format
15+ // json "fix it"` — where Codey's -p takes the prompt as its value; and
16+ // --allowedTools takes several values in a row. prepareArgs reads the line
17+ // first and hands the flag package something it can parse, so that both
18+ // `codey -p "fix it"` and the Claude Code spelling work.
19+
20+ import (
21+ "errors"
22+ "flag"
23+ "fmt"
24+ "io"
25+ "os"
26+ "strings"
27+
28+ "golang.org/x/term"
29+
30+ "github.com/johannSo/codey/internal/approval"
31+ )
32+
33+ // Output formats `-p` can print in. The empty format is Codey's own: the
34+ // streamed transcript and a usage line, for a person reading along.
35+ const (
36+ formatText = "text"
37+ formatJSON = "json"
38+ formatStreamJSON = "stream-json"
39+ )
40+
41+ // claudeFlags are the flags `claude` takes that Codey answers to as well.
42+ type claudeFlags struct {
43+ print bool
44+ outputFormat string
45+ inputFormat string
46+ verbose bool
47+ cont bool
48+ allowed ruleList
49+ disallowed ruleList
50+ appendSystem string
51+ systemPrompt string
52+ permissionMode string
53+ skipPermissions bool
54+ partial bool
55+ }
56+
57+ // ruleList collects permission rules from every time the flag is given, each
58+ // time a list of its own: --allowedTools "Read,Edit" --allowedTools "Bash(go test:*)".
59+ type ruleList []string
60+
61+ func (l *ruleList) String() string { return strings.Join(*l, ",") }
62+
63+ func (l *ruleList) Set(v string) error {
64+ *l = append(*l, approval.SplitRules(v)...)
65+ return nil
66+ }
67+
68+ // ruleFlags are the flags prepareArgs lets take several values in a row.
69+ var ruleFlags = map[string]bool{
70+ "allowedTools": true, "allowed-tools": true,
71+ "disallowedTools": true, "disallowed-tools": true,
72+ }
73+
74+ func registerClaudeFlags(fs *flag.FlagSet) *claudeFlags {
75+ c := &claudeFlags{}
76+ fs.BoolVar(&c.print, "print", false, "the same as -p; the prompt may follow it, or come after the other flags, or on stdin")
77+ fs.StringVar(&c.outputFormat, "output-format", "", "with -p: text (the answer only), json (one result object) or stream-json (one line per message), in the shapes claude -p prints")
78+ fs.StringVar(&c.inputFormat, "input-format", formatText, "with -p: only text is supported")
79+ fs.BoolVar(&c.verbose, "verbose", false, "the same as -v")
80+ fs.BoolVar(&c.cont, "c", false, "continue the most recent session in this working root, the same as -resume latest")
81+ fs.BoolVar(&c.cont, "continue", false, "the same as -c")
82+ ruleHelp := "comma- or space-separated rules in Claude Code's syntax: Bash, Bash(git diff:*), Read(./src/**), mcp__server"
83+ fs.Var(&c.allowed, "allowedTools", "tool calls to approve without asking; "+ruleHelp)
84+ fs.Var(&c.allowed, "allowed-tools", "the same as -allowedTools")
85+ fs.Var(&c.disallowed, "disallowedTools", "tools to take away, or calls to refuse whatever else says yes; "+ruleHelp)
86+ fs.Var(&c.disallowed, "disallowed-tools", "the same as -disallowedTools")
87+ fs.StringVar(&c.appendSystem, "append-system-prompt", "", "text added to the end of the system prompt")
88+ fs.StringVar(&c.systemPrompt, "system-prompt", "", "replace Codey's system prompt with this text")
89+ fs.StringVar(&c.permissionMode, "permission-mode", "", "default, acceptEdits (edits without asking), bypassPermissions (the same as -yolo) or dontAsk (refuse what no rule allows)")
90+ fs.BoolVar(&c.skipPermissions, "dangerously-skip-permissions", false, "the same as -yolo")
91+ fs.BoolVar(&c.partial, "include-partial-messages", false, "accepted for compatibility and ignored: stream-json prints whole messages only")
92+ return c
93+ }
94+
95+ // prepareArgs rewrites the command line into one the flag package reads the
96+ // way `claude` would have.
97+ //
98+ // Go's flag parsing stops at the first argument that is not a flag, and
99+ // commander, which `claude` uses, does not: `claude "fix it" --model x` is a
100+ // prompt and a model. So the flags are pulled out wherever they are, and the
101+ // positional arguments go after a "--" at the end, where flag.Args finds them.
102+ // Knowing which flags take a value is what makes that possible, and the flag
103+ // set already knows.
104+ //
105+ // -p and --print take the next argument as the prompt when it is not a flag,
106+ // which is both Codey's -p "prompt" and Claude Code's -p "prompt"; followed by
107+ // a flag or by nothing, they are a switch, and the prompt is whatever
108+ // positional arguments there are, or stdin. The rule flags take every
109+ // following argument that reads as a rule, as commander's variadic options do
110+ // — except that "reads as a rule" here means it parses as one, so a one-word
111+ // prompt after --allowedTools Read is still a prompt.
112+ //
113+ // Anything it does not recognise is passed through untouched, for the flag
114+ // package to report in its own words.
115+ func prepareArgs(fs *flag.FlagSet, args []string, c *claudeFlags) []string {
116+ var flags, positional []string
117+ for i := 0; i < len(args); i++ {
118+ a := args[i]
119+ if a == "--" {
120+ positional = append(positional, args[i+1:]...)
121+ break
122+ }
123+ if !isFlag(a) {
124+ positional = append(positional, a)
125+ continue
126+ }
127+ name, _, hasValue := strings.Cut(strings.TrimLeft(a, "-"), "=")
128+
129+ if (name == "p" || name == "print") && !hasValue {
130+ if i+1 < len(args) && !isFlag(args[i+1]) {
131+ flags = append(flags, "-p", args[i+1])
132+ i++
133+ continue
134+ }
135+ c.print = true
136+ continue
137+ }
138+
139+ f := fs.Lookup(name)
140+ if f == nil || hasValue || isBoolFlag(f) || i+1 >= len(args) {
141+ flags = append(flags, a)
142+ continue
143+ }
144+ flags = append(flags, a, args[i+1])
145+ i++
146+ if ruleFlags[name] {
147+ for i+1 < len(args) && !isFlag(args[i+1]) && readsAsRules(args[i+1]) {
148+ flags = append(flags, a, args[i+1])
149+ i++
150+ }
151+ }
152+ }
153+ return append(append(flags, "--"), positional...)
154+ }
155+
156+ // isFlag is the flag package's own test: a dash and something after it.
157+ func isFlag(a string) bool { return len(a) > 1 && a[0] == '-' }
158+
159+ func isBoolFlag(f *flag.Flag) bool {
160+ b, ok := f.Value.(interface{ IsBoolFlag() bool })
161+ return ok && b.IsBoolFlag()
162+ }
163+
164+ // readsAsRules reports whether every item in an argument parses as a rule.
165+ func readsAsRules(a string) bool {
166+ items := approval.SplitRules(a)
167+ if len(items) == 0 {
168+ return false
169+ }
170+ for _, s := range items {
171+ if _, err := approval.ParseRule(s); err != nil {
172+ return false
173+ }
174+ }
175+ return true
176+ }
177+
178+ // cliRun is what the Claude Code flags came to.
179+ type cliRun struct {
180+ // Prompt is the -p prompt, put together from the flag, the positional
181+ // arguments and stdin; empty for an interactive session.
182+ Prompt string
183+ // FromStdin is set when the prompt was read from stdin, which leaves
184+ // nothing there to read an approval from.
185+ FromStdin bool
186+ // Format is the -p output format, empty for Codey's own.
187+ Format string
188+ // Resume is -resume, or "latest" for --continue.
189+ Resume string
190+ // YOLO is --dangerously-skip-permissions or bypassPermissions.
191+ YOLO bool
192+ // NoPrompt refuses whatever no rule allows rather than asking.
193+ NoPrompt bool
194+ // Mode is the permission mode, in Claude Code's words, for the init line.
195+ Mode string
196+ // Rules go on the approval policy; Drop is the part of them that takes
197+ // whole tools away, for the registry; and DropHostedSearch is the same
198+ // for the one tool the registry never holds.
199+ Rules approval.RuleList
200+ Drop func(name string) bool
201+ DropHostedSearch bool
202+ // System replaces the system prompt when set, and SystemAppend goes on
203+ // the end of it.
204+ System, SystemAppend string
205+ // Warnings are for the user, once there is somewhere to say them.
206+ Warnings []string
207+ }
208+
209+ // UIOut is where the line front end may write its prompts. In a Claude-shaped
210+ // run stdout belongs to the output format, so a question goes to stderr.
211+ func (r cliRun) UIOut() io.Writer {
212+ if r.Format != "" {
213+ return os.Stderr
214+ }
215+ return os.Stdout
216+ }
217+
218+ // resolve turns the parsed flags into a cliRun. prompt and resume are Codey's
219+ // own -p and -resume, positional is what flag parsing left over, and root is
220+ // what relative path rules are read against.
221+ func (c *claudeFlags) resolve(prompt string, positional []string, resume, root string, stdin *os.File) (cliRun, error) {
222+ r := cliRun{Resume: resume, System: c.systemPrompt, SystemAppend: c.appendSystem, Mode: "default"}
223+
224+ printing := c.print || prompt != ""
225+ switch c.outputFormat {
226+ case "", formatText, formatJSON, formatStreamJSON:
227+ default:
228+ return r, fmt.Errorf("--output-format %q: want text, json or stream-json", c.outputFormat)
229+ }
230+ if c.outputFormat != "" && !printing {
231+ return r, errors.New("--output-format applies only with -p")
232+ }
233+ r.Format = c.outputFormat
234+ if c.inputFormat != formatText {
235+ return r, fmt.Errorf("--input-format %q is not supported: the prompt is read as text", c.inputFormat)
236+ }
237+ if c.partial {
238+ r.Warnings = append(r.Warnings, "--include-partial-messages is ignored: stream-json prints whole messages only")
239+ }
240+
241+ if c.cont {
242+ if resume != "" && resume != "latest" {
243+ return r, fmt.Errorf("--continue and -resume %s ask for different sessions; give one", resume)
244+ }
245+ r.Resume = "latest"
246+ }
247+
248+ if printing {
249+ parts := positional
250+ if prompt != "" {
251+ parts = append([]string{prompt}, positional...)
252+ }
253+ r.Prompt = strings.TrimSpace(strings.Join(parts, " "))
254+ if r.Prompt == "" && !isTerminal(stdin) {
255+ // What Claude Code does with `echo "fix it" | claude -p`. Stdin
256+ // is read only when there is no prompt otherwise: a CI runner can
257+ // leave it as a pipe nobody ever closes, and a run that waited on
258+ // it would hang there for good.
259+ b, err := io.ReadAll(stdin)
260+ if err != nil {
261+ return r, fmt.Errorf("reading the prompt from stdin: %w", err)
262+ }
263+ r.Prompt, r.FromStdin = strings.TrimSpace(string(b)), true
264+ }
265+ if r.Prompt == "" {
266+ return r, errors.New("-p needs a prompt: give it after -p, as an argument, or on stdin")
267+ }
268+ } else if len(positional) > 0 {
269+ r.Warnings = append(r.Warnings, fmt.Sprintf("ignoring %q: a prompt on the command line needs -p", strings.Join(positional, " ")))
270+ }
271+
272+ // With nobody at a terminal there is nobody to ask, and a Claude-shaped
273+ // run never asks anyway: what no rule allows is refused, and the model
274+ // is told so. Codey's own -p keeps reading answers from stdin, which is
275+ // how `yes | codey -p ...` has always worked.
276+ r.NoPrompt = r.FromStdin || (r.Format != "" && !isTerminal(stdin))
277+
278+ allow, errs := approval.ParseRules(c.allowed)
279+ deny, denyErrs := approval.ParseRules(c.disallowed)
280+ for _, err := range append(errs, denyErrs...) {
281+ r.Warnings = append(r.Warnings, fmt.Sprintf("%v — ignored", err))
282+ }
283+
284+ r.YOLO = c.skipPermissions
285+ switch c.permissionMode {
286+ case "", "default":
287+ case "acceptEdits":
288+ // The mode the ACP front end calls by the same name: edits go
289+ // through, commands still ask. An Edit rule says exactly that.
290+ edit, _ := approval.ParseRule("Edit")
291+ allow = append(allow, edit)
292+ r.Mode = c.permissionMode
293+ case "bypassPermissions":
294+ r.YOLO, r.Mode = true, c.permissionMode
295+ case "dontAsk":
296+ r.NoPrompt, r.Mode = true, c.permissionMode
297+ case "plan":
298+ return r, errors.New("--permission-mode plan: plan mode is not supported — Codey has no read-only planning mode. Ask for a plan in the prompt, and use --disallowedTools Edit,Write,Bash to hold it to that")
299+ default:
300+ return r, fmt.Errorf("--permission-mode %q: want default, acceptEdits, bypassPermissions or dontAsk", c.permissionMode)
301+ }
302+ if r.YOLO {
303+ r.Mode = "bypassPermissions"
304+ }
305+
306+ for i := range allow {
307+ allow[i].Root = root
308+ }
309+ for i := range deny {
310+ deny[i].Root = root
311+ }
312+ r.Rules = approval.RuleList{Allow: allow, Deny: deny}
313+
314+ var whole []approval.Rule
315+ for _, d := range deny {
316+ if d.WholeTool() {
317+ whole = append(whole, d)
318+ }
319+ }
320+ r.Drop = func(name string) bool {
321+ for _, d := range whole {
322+ if d.Covers(name) {
323+ return true
324+ }
325+ }
326+ return false
327+ }
328+ r.DropHostedSearch = r.Drop("web_search")
329+ return r, nil
330+ }
331+
332+ // isTerminal reports whether f is a terminal rather than a pipe or a file. It
333+ // asks the terminal driver rather than checking for a character device,
334+ // because /dev/null is one too — and `< /dev/null` is exactly how a CI job
335+ // says there is nobody here.
336+ func isTerminal(f *os.File) bool {
337+ return term.IsTerminal(int(f.Fd()))
338+ }
+256−0cmd/codey/claudecli_test.goaddedView File
@@ -0,0 +1,256 @@
1+ package main
2+
3+ import (
4+ "encoding/json"
5+ "flag"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "slices"
10+ "strings"
11+ "testing"
12+
13+ "github.com/johannSo/codey/internal/approval"
14+ )
15+
16+ // testFlags is a flag set shaped like main's: the Codey flags the pre-pass has
17+ // to know the arity of, and the Claude Code ones.
18+ func testFlags() (*flag.FlagSet, *claudeFlags, *string, *string, *string, *bool) {
19+ fs := flag.NewFlagSet("codey", flag.ContinueOnError)
20+ fs.SetOutput(io.Discard)
21+ prompt := fs.String("p", "", "")
22+ model := fs.String("model", "", "")
23+ resume := fs.String("resume", "", "")
24+ v := fs.Bool("v", false, "")
25+ fs.Int("max-turns", 0, "")
26+ fs.String("C", ".", "")
27+ fs.Bool("yolo", false, "")
28+ return fs, registerClaudeFlags(fs), prompt, model, resume, v
29+ }
30+
31+ func TestPrepareArgsReadsBothSpellingsOfP(t *testing.T) {
32+ cases := []struct {
33+ name string
34+ args []string
35+ wantPrompt string // what -p ends up holding
36+ wantPrint bool // -p given as a switch
37+ wantArgs []string
38+ wantModel string
39+ }{
40+ {name: "codey style", args: []string{"-p", "fix it"}, wantPrompt: "fix it"},
41+ {name: "claude style, prompt last", args: []string{"-p", "--output-format", "json", "fix it"}, wantPrint: true, wantArgs: []string{"fix it"}},
42+ {name: "--print", args: []string{"--print", "--model", "m", "fix", "it"}, wantPrint: true, wantArgs: []string{"fix", "it"}, wantModel: "m"},
43+ {name: "prompt first, flags after", args: []string{"fix it", "-p", "--model", "m"}, wantPrint: true, wantArgs: []string{"fix it"}, wantModel: "m"},
44+ {name: "-p with its prompt, then flags", args: []string{"-p", "fix it", "--model=m"}, wantPrompt: "fix it", wantModel: "m"},
45+ {name: "-p alone", args: []string{"-p"}, wantPrint: true},
46+ {name: "-p= keeps a leading dash", args: []string{"-p=-x is broken"}, wantPrompt: "-x is broken"},
47+ {name: "double-dash value flags", args: []string{"--model", "m", "--max-turns", "3", "-p", "go"}, wantPrompt: "go", wantModel: "m"},
48+ {name: "bool flags take no value", args: []string{"-p", "-v", "go"}, wantPrint: true, wantArgs: []string{"go"}},
49+ {name: "-- ends the flags", args: []string{"-p", "--", "--model", "x"}, wantPrint: true, wantArgs: []string{"--model", "x"}},
50+ }
51+ for _, c := range cases {
52+ t.Run(c.name, func(t *testing.T) {
53+ fs, cc, prompt, model, _, _ := testFlags()
54+ if err := fs.Parse(prepareArgs(fs, c.args, cc)); err != nil {
55+ t.Fatalf("parse: %v", err)
56+ }
57+ if *prompt != c.wantPrompt || cc.print != c.wantPrint || *model != c.wantModel {
58+ t.Errorf("-p=%q print=%v model=%q, want %q %v %q", *prompt, cc.print, *model, c.wantPrompt, c.wantPrint, c.wantModel)
59+ }
60+ if got := fs.Args(); !slices.Equal(got, c.wantArgs) && !(len(got) == 0 && len(c.wantArgs) == 0) {
61+ t.Errorf("positional = %q, want %q", got, c.wantArgs)
62+ }
63+ })
64+ }
65+ }
66+
67+ func TestPrepareArgsTakesSeveralRulesInARow(t *testing.T) {
68+ fs, cc, _, _, _, _ := testFlags()
69+ args := []string{"-p", "--allowedTools", "Read", "Bash(git log:*)", "Edit,Write", "summarise the log", "--disallowedTools", "WebFetch"}
70+ if err := fs.Parse(prepareArgs(fs, args, cc)); err != nil {
71+ t.Fatal(err)
72+ }
73+ if want := []string{"Read", "Bash(git log:*)", "Edit", "Write"}; !slices.Equal(cc.allowed, want) {
74+ t.Errorf("allowed = %q, want %q", cc.allowed, want)
75+ }
76+ if !slices.Equal(cc.disallowed, []string{"WebFetch"}) {
77+ t.Errorf("disallowed = %q", cc.disallowed)
78+ }
79+ if got := fs.Args(); !slices.Equal(got, []string{"summarise the log"}) {
80+ t.Errorf("the prompt should stop the rule list: positional = %q", got)
81+ }
82+
83+ // A one-word prompt is not a tool name, so it is not eaten as a rule.
84+ fs, cc, _, _, _, _ = testFlags()
85+ fs.Parse(prepareArgs(fs, []string{"-p", "--allowed-tools", "Read", "hello"}, cc))
86+ if !slices.Equal(cc.allowed, []string{"Read"}) || !slices.Equal(fs.Args(), []string{"hello"}) {
87+ t.Errorf("allowed=%q positional=%q", cc.allowed, fs.Args())
88+ }
89+ }
90+
91+ func TestPrepareArgsLeavesTheUnknownToTheFlagPackage(t *testing.T) {
92+ fs, cc, _, _, _, _ := testFlags()
93+ if err := fs.Parse(prepareArgs(fs, []string{"--mcp-config", "x.json", "-p", "go"}, cc)); err == nil {
94+ t.Error("an unknown flag should still be an error")
95+ }
96+ }
97+
98+ // The agent-run pre-parse in agentcmd.go still sees the line first, and an
99+ // ordinary line still comes through it untouched for prepareArgs.
100+ func TestScanArgsStillHandsOrdinaryLinesOn(t *testing.T) {
101+ args := []string{"-p", "--permission-mode", "acceptEdits", "--output-format", "json", "go"}
102+ inv := scanArgs(args)
103+ if inv.Kind != invNormal || !slices.Equal(inv.Rest, args) {
104+ t.Fatalf("scanArgs = %+v", inv)
105+ }
106+ fs, cc, _, _, _, _ := testFlags()
107+ if err := fs.Parse(prepareArgs(fs, inv.Rest, cc)); err != nil {
108+ t.Fatal(err)
109+ }
110+ if cc.permissionMode != "acceptEdits" || cc.outputFormat != "json" || !cc.print {
111+ t.Errorf("flags = %+v", cc)
112+ }
113+ }
114+
115+ // ttyless is a stdin that is a file, not a terminal: what a CI job has.
116+ func ttyless(t *testing.T, content string) *os.File {
117+ t.Helper()
118+ p := filepath.Join(t.TempDir(), "stdin")
119+ os.WriteFile(p, []byte(content), 0o644)
120+ f, err := os.Open(p)
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ t.Cleanup(func() { f.Close() })
125+ return f
126+ }
127+
128+ func parseResolve(t *testing.T, stdin *os.File, args ...string) (cliRun, error) {
129+ t.Helper()
130+ fs, cc, prompt, _, resume, _ := testFlags()
131+ if err := fs.Parse(prepareArgs(fs, args, cc)); err != nil {
132+ t.Fatalf("parse %q: %v", args, err)
133+ }
134+ return cc.resolve(*prompt, fs.Args(), *resume, "/work", stdin)
135+ }
136+
137+ func TestResolveJoinsThePrompt(t *testing.T) {
138+ r, err := parseResolve(t, ttyless(t, "ignored"), "-p", "fix", "the", "bug")
139+ if err != nil || r.Prompt != "fix the bug" || r.FromStdin {
140+ t.Errorf("prompt=%q fromStdin=%v err=%v", r.Prompt, r.FromStdin, err)
141+ }
142+ }
143+
144+ func TestResolveReadsThePromptFromStdin(t *testing.T) {
145+ r, err := parseResolve(t, ttyless(t, " explain main.go\n"), "-p", "--output-format", "json")
146+ if err != nil {
147+ t.Fatal(err)
148+ }
149+ if r.Prompt != "explain main.go" || !r.FromStdin || !r.NoPrompt || r.Format != formatJSON {
150+ t.Errorf("got %+v", r)
151+ }
152+ if _, err := parseResolve(t, ttyless(t, ""), "-p"); err == nil || !strings.Contains(err.Error(), "needs a prompt") {
153+ t.Errorf("no prompt anywhere should be an error, got %v", err)
154+ }
155+ }
156+
157+ func TestResolveRefusesWhatItCannotDo(t *testing.T) {
158+ stdin := ttyless(t, "")
159+ for _, args := range [][]string{
160+ {"-p", "x", "--output-format", "yaml"},
161+ {"--output-format", "json"},
162+ {"-p", "x", "--input-format", "stream-json"},
163+ {"-p", "x", "--permission-mode", "plan"},
164+ {"-p", "x", "--permission-mode", "whatever"},
165+ {"-c", "-resume", "20260101T000000Z", "-p", "x"},
166+ } {
167+ if _, err := parseResolve(t, stdin, args...); err == nil {
168+ t.Errorf("%q should be refused", args)
169+ }
170+ }
171+ _, err := parseResolve(t, stdin, "-p", "x", "--permission-mode", "plan")
172+ if err == nil || !strings.Contains(err.Error(), "plan mode is not supported") {
173+ t.Errorf("plan mode should say so plainly, got %v", err)
174+ }
175+ }
176+
177+ func TestResolvePermissionModes(t *testing.T) {
178+ stdin := ttyless(t, "")
179+ edit := json.RawMessage(`{"path":"a.go"}`)
180+
181+ r, _ := parseResolve(t, stdin, "-p", "x", "--permission-mode", "acceptEdits")
182+ if v := r.Rules.Check("write_file", edit).Verdict; v != approval.VerdictAllow || r.Mode != "acceptEdits" || r.YOLO {
183+ t.Errorf("acceptEdits should allow edits and only edits: %+v", r)
184+ }
185+ if v := r.Rules.Check("bash", json.RawMessage(`{"command":"ls"}`)).Verdict; v == approval.VerdictAllow {
186+ t.Error("acceptEdits still asks before a command")
187+ }
188+
189+ for _, args := range [][]string{
190+ {"-p", "x", "--permission-mode", "bypassPermissions"},
191+ {"-p", "x", "--dangerously-skip-permissions"},
192+ } {
193+ if r, _ := parseResolve(t, stdin, args...); !r.YOLO || r.Mode != "bypassPermissions" {
194+ t.Errorf("%q should be -yolo: %+v", args, r)
195+ }
196+ }
197+ if r, _ := parseResolve(t, stdin, "-p", "x", "--permission-mode", "dontAsk"); !r.NoPrompt {
198+ t.Error("dontAsk refuses rather than asking")
199+ }
200+ // Codey's own -p keeps asking on stdin, as `yes | codey -p` relies on.
201+ if r, _ := parseResolve(t, stdin, "-p", "x"); r.NoPrompt {
202+ t.Error("plain -p with a prompt argument should still read approvals from stdin")
203+ }
204+ // A Claude-shaped run with no terminal never asks.
205+ if r, _ := parseResolve(t, stdin, "-p", "x", "--output-format", "text"); !r.NoPrompt {
206+ t.Error("an output format with no terminal should not ask")
207+ }
208+ }
209+
210+ func TestResolveContinue(t *testing.T) {
211+ stdin := ttyless(t, "")
212+ for _, args := range [][]string{{"-c", "-p", "x"}, {"--continue", "-p", "x"}, {"-c", "-resume", "latest", "-p", "x"}} {
213+ if r, err := parseResolve(t, stdin, args...); err != nil || r.Resume != "latest" {
214+ t.Errorf("%q: resume=%q err=%v", args, r.Resume, err)
215+ }
216+ }
217+ if r, _ := parseResolve(t, stdin, "--resume", "abc", "-p", "x"); r.Resume != "abc" {
218+ t.Errorf("--resume <id> is unchanged, got %q", r.Resume)
219+ }
220+ }
221+
222+ func TestResolveRules(t *testing.T) {
223+ stdin := ttyless(t, "")
224+ r, err := parseResolve(t, stdin, "-p", "x",
225+ "--allowedTools", "Bash(go test:*),Read(./src/**)",
226+ "--disallowedTools", "WebSearch Bash(git push:*) mcp__db NotebookEdit")
227+ if err != nil {
228+ t.Fatal(err)
229+ }
230+ if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "NotebookEdit") {
231+ t.Errorf("an unknown tool is a warning, not an error: %q", r.Warnings)
232+ }
233+ for name, want := range map[string]bool{
234+ "web_search": true, "mcp__db__query": true, "bash": false, "read_file": false,
235+ } {
236+ if got := r.Drop(name); got != want {
237+ t.Errorf("Drop(%q) = %v, want %v", name, got, want)
238+ }
239+ }
240+ if !r.DropHostedSearch {
241+ t.Error("WebSearch disallowed should take hosted search off the request too")
242+ }
243+ if v := r.Rules.Check("bash", json.RawMessage(`{"command":"go test ./... && git push"}`)).Verdict; v != approval.VerdictDeny {
244+ t.Errorf("a scoped deny rule is enforced at approval time, got %v", v)
245+ }
246+ if v := r.Rules.Check("read_file", json.RawMessage(`{"path":"/work/src/a.go"}`)).Verdict; v != approval.VerdictAllow {
247+ t.Errorf("relative path rules are read against the working root, got %v", v)
248+ }
249+ }
250+
251+ func TestResolveWarnsAboutAPromptWithoutP(t *testing.T) {
252+ r, err := parseResolve(t, ttyless(t, ""), "fix it")
253+ if err != nil || r.Prompt != "" || len(r.Warnings) != 1 {
254+ t.Errorf("prompt=%q warnings=%q err=%v", r.Prompt, r.Warnings, err)
255+ }
256+ }
+51−6cmd/codey/main.goView File
@@ -63,6 +63,7 @@ const hostedSearchesPerTurn = 5
6363
6464 func main() {
6565 if err := run(); err != nil {
66+ reportFailure(err)
6667 fmt.Fprintln(os.Stderr, "codey:", err)
6768 os.Exit(1)
6869 }
@@ -97,6 +98,8 @@ func run() error {
9798 noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it")
9899 noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered")
99100 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")
101+ // The flags a script written for `claude -p` passes — see claudecli.go.
102+ claudeFlags := registerClaudeFlags(flag.CommandLine)
100103
101104 // Read the line before the flag package does. Three invocations belong to
102105 // a program driving Codey rather than to a person — see agentcmd.go — and
@@ -106,7 +109,7 @@ func run() error {
106109 printVersion(os.Stdout)
107110 return nil
108111 }
109- if err := flag.CommandLine.Parse(inv.Rest); err != nil {
112+ if err := flag.CommandLine.Parse(prepareArgs(flag.CommandLine, inv.Rest, claudeFlags)); err != nil {
110113 return err
111114 }
112115
@@ -115,6 +118,19 @@ func run() error {
115118 return err
116119 }
117120
121+ // What the Claude Code flags come to, folded into Codey's own from here
122+ // on, so nothing below needs to know which spelling was used.
123+ cli, err := claudeFlags.resolve(*prompt, flag.Args(), *resume, root, os.Stdin)
124+ if err != nil {
125+ return err
126+ }
127+ *prompt, *resume = cli.Prompt, cli.Resume
128+ *verbose = *verbose || claudeFlags.verbose
129+ *yolo = *yolo || cli.YOLO
130+ if cli.Format != "" {
131+ claudeOut = newPrinter(os.Stdout, os.Stderr, cli.Format, *verbose)
132+ }
133+
118134 // A clone last used under the old name keeps its sessions and its event
119135 // log. This runs before anything opens a file inside the state directory,
120136 // and is reported rather than fatal: a stale directory that cannot be
@@ -237,6 +253,9 @@ func run() error {
237253 fmt.Fprintln(os.Stderr, "codey: "+msg)
238254 }
239255
256+ for _, w := range cli.Warnings {
257+ notice("%s", w)
258+ }
240259 if cfgErr != nil {
241260 notice("%v — using defaults; /setup will write a fresh one", cfgErr)
242261 }
@@ -348,6 +367,11 @@ func run() error {
348367 }
349368 }
350369 }
370+ // Hosted search is never in the registry, so --disallowedTools WebSearch
371+ // has to take it off the request instead.
372+ if cli.DropHostedSearch {
373+ serverTools = nil
374+ }
351375
352376 // Whatever this machine can say about a file the agent has just written.
353377 // A nil Runner is the off switch, and every call site takes one, so the
@@ -432,9 +456,13 @@ func run() error {
432456 // /worktree rebuilds the tool set against a different directory: each tool
433457 // closes over the root it is handed, so moving the work means building the
434458 // set again, and this is the only place that knows how.
459+ //
460+ // A tool --disallowedTools takes away is kept out here, before anything
461+ // is registered, so it is missing from every registry built — subagents'
462+ // and the tools main adds afterwards alike.
435463 toolsIn := func(root string) func(*agent.State, *metrics.Logger) *tools.Registry {
436464 return func(state *agent.State, log *metrics.Logger) *tools.Registry {
437- reg := tools.NewRegistry(log).Register(
465+ reg := tools.NewRegistry(log).Exclude(cli.Drop).Register(
438466 tools.NewRead(root, state, skillDirs...),
439467 tools.NewGlob(root, skillDirs...),
440468 tools.NewGrep(root, skillDirs...),
@@ -473,15 +501,21 @@ func run() error {
473501 in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
474502
475503 render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
476- approver := &ui.Approver{In: in, Out: os.Stdout}
504+ approver := &ui.Approver{In: in, Out: cli.UIOut()}
477505 policy := approval.New(approver.Prompt)
478- policy.Rules = cc.Rules
506+ // The settings files' rules and the command line's, decided together:
507+ // a deny in either holds, whatever the other allows.
508+ policy.Rules = approval.Combine(cc.Rules, cli.Rules)
509+ if cli.NoPrompt {
510+ // Nobody to ask: a nil prompt refuses, and tells the model why.
511+ policy.Prompt = nil
512+ }
479513
480514 // Questions go to the line front end until a TUI takes them over in
481515 // tui.Run. The tool belongs to the session's own agent and is registered
482516 // here rather than in newTools, so a subagent still cannot stop and ask:
483517 // its report goes to the parent, and the parent is not a person.
484- asker := ask.NewAsker((&ui.Asker{In: in, Out: os.Stdout}).Prompt)
518+ asker := ask.NewAsker((&ui.Asker{In: in, Out: cli.UIOut()}).Prompt)
485519 reg.Register(ask.Tool(asker.Ask))
486520 // The plan belongs to the session agent for the same reason the question
487521 // does: a subagent keeping its own list would show the user a plan for
@@ -577,6 +611,11 @@ func run() error {
577611 base.Models = models
578612 }
579613
614+ // The system prompt flags are for the session's own agents, the tabs
615+ // below included, and not for subagents: the supervisors were handed
616+ // their options above, and a child's prompt is its preset's.
617+ base.System, base.SystemAppend = cli.System, cli.SystemAppend
618+
580619 a := agent.New(client, reg, state, log, base)
581620 a.Observe = render.Handle
582621 a.Approve = policy
@@ -713,7 +752,7 @@ func run() error {
713752 // to come up in the tab whose agent is asking.
714753 pol := approval.New(nil)
715754 pol.YOLO = acceptAll
716- pol.Rules = cc.Rules
755+ pol.Rules = approval.Combine(cc.Rules, cli.Rules)
717756
718757 var tabSup *subagent.Supervisor
719758 if !*noAgents {
@@ -857,6 +896,12 @@ func run() error {
857896 ctx, stopSignals := interruptible(context.Background())
858897 defer stopSignals()
859898
899+ if *prompt != "" && claudeOut != nil {
900+ claudeOut.session = session
901+ a.Observe = claudeOut.observe
902+ claudeOut.begin(workRoot, client.Model(), cli.Mode, toolNames(reg))
903+ return printOnce(ctx, a, claudeOut, sessionDir, session, root, client, *prompt)
904+ }
860905 if *prompt != "" {
861906 return once(ctx, a, render, sessionDir, session, root, client, *prompt)
862907 }
+408−0cmd/codey/printout.goaddedView File
@@ -0,0 +1,408 @@
1+ package main
2+
3+ // What `-p --output-format ...` prints, in the shapes `claude -p` prints.
4+ //
5+ // The reader is a program: a CI step that runs `jq -r .result`, a wrapper that
6+ // reads stream-json line by line to show progress, an SDK that was written
7+ // against Claude Code. None of them can be asked to change, so the shapes are
8+ // Claude Code's — the same type and subtype names, the same field names, the
9+ // assistant message as the Messages API returns it — and where Codey has
10+ // nothing to put in a field Claude Code fills, the field is left out rather
11+ // than filled with something made up.
12+ //
13+ // Nothing else may reach stdout in these modes. Notices go to stderr, and so
14+ // do approval prompts; see cliRun.UIOut.
15+
16+ import (
17+ "bytes"
18+ "context"
19+ "crypto/rand"
20+ "encoding/json"
21+ "errors"
22+ "fmt"
23+ "io"
24+ "os"
25+ "sort"
26+ "strings"
27+ "sync"
28+ "time"
29+
30+ "github.com/johannSo/codey/internal/agent"
31+ "github.com/johannSo/codey/internal/llm"
32+ )
33+
34+ // Result subtypes, as Claude Code names them.
35+ const (
36+ resultSuccess = "success"
37+ resultMaxTurns = "error_max_turns"
38+ resultExecution = "error_during_execution"
39+ )
40+
41+ // printer writes one run in a Claude Code output format. It is the agent's
42+ // observer for the length of the run, and the event log of it as far as the
43+ // reader on stdout is concerned.
44+ type printer struct {
45+ mu sync.Mutex
46+ w io.Writer
47+ notes io.Writer
48+ format string
49+ // array is json with --verbose, which Claude Code prints as every line
50+ // stream-json would have printed, collected into one JSON array.
51+ array bool
52+ records []any
53+
54+ session string
55+ model string
56+ start time.Time
57+ // api is the time spent waiting on the model, measured from the start of
58+ // each turn to the usage that ends it. Compaction is a model call too and
59+ // falls inside that window, which is where it belongs.
60+ api time.Duration
61+ turnStart time.Time
62+ done bool
63+
64+ // now and newID are the clock and the uuid source, replaceable so a test
65+ // can compare output byte for byte.
66+ now func() time.Time
67+ newID func() string
68+ }
69+
70+ func newPrinter(w, notes io.Writer, format string, verbose bool) *printer {
71+ return &printer{
72+ w: w, notes: notes, format: format,
73+ array: format == formatJSON && verbose,
74+ start: time.Now(), now: time.Now, newID: uuid4,
75+ }
76+ }
77+
78+ // claudeOut is the printer of a Claude-shaped -p run, once there is one. It is
79+ // a package variable for main, which reports a run that failed before it got
80+ // as far as the model — no credentials, a config error — and whose reader is
81+ // still waiting for a result object.
82+ var claudeOut *printer
83+
84+ // reportFailure gives the reader on stdout its result object when the run
85+ // ended before printing one.
86+ func reportFailure(err error) {
87+ if claudeOut != nil {
88+ claudeOut.finish(outcome{Err: err})
89+ }
90+ }
91+
92+ // begin prints the init line, which only stream-json has.
93+ func (p *printer) begin(cwd, model, mode string, tools []string) {
94+ p.mu.Lock()
95+ defer p.mu.Unlock()
96+ p.model = model
97+ p.start = p.now()
98+ if p.format != formatStreamJSON && !p.array {
99+ return
100+ }
101+ p.emit(initLine{
102+ Type: "system", Subtype: "init", CWD: cwd, SessionID: p.session,
103+ Tools: tools, MCPServers: mcpServers(tools), Model: model,
104+ PermissionMode: mode, UUID: p.newID(),
105+ })
106+ }
107+
108+ // observe is the agent's Observe callback.
109+ func (p *printer) observe(ev agent.Event) {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
112+ switch ev.Kind {
113+ case agent.EventTurnStart:
114+ p.turnStart = p.now()
115+ case agent.EventUsage:
116+ if !p.turnStart.IsZero() {
117+ p.api += p.now().Sub(p.turnStart)
118+ p.turnStart = time.Time{}
119+ }
120+ case agent.EventNotice:
121+ fmt.Fprintln(p.notes, "codey: "+strings.TrimSpace(ev.Text))
122+ }
123+ if p.format != formatStreamJSON && !p.array {
124+ return
125+ }
126+ switch ev.Kind {
127+ case agent.EventMessage:
128+ if r := ev.Response; r != nil {
129+ model := r.Model
130+ if model == "" {
131+ model = p.model
132+ }
133+ p.emit(messageLine{
134+ Type: "assistant",
135+ Message: apiMessage{
136+ ID: r.ID, Type: "message", Role: llm.RoleAssistant, Model: model,
137+ Content: nonNil(r.Content), StopReason: r.StopReason, Usage: r.Usage,
138+ },
139+ SessionID: p.session, UUID: p.newID(),
140+ })
141+ }
142+ if m := ev.Message; m != nil {
143+ p.emit(messageLine{
144+ Type: "user",
145+ Message: userMessage{Role: m.Role, Content: nonNil(m.Content)},
146+ SessionID: p.session, UUID: p.newID(),
147+ })
148+ }
149+ case agent.EventCompact:
150+ p.emit(systemLine{Type: "system", Subtype: "compact_boundary", SessionID: p.session, UUID: p.newID()})
151+ }
152+ }
153+
154+ // outcome is how a run ended, for the result object.
155+ type outcome struct {
156+ Text string
157+ Err error
158+ Turns int
159+ Usage llm.Usage
160+ }
161+
162+ // finish prints the result, once. A second call — main reporting the error
163+ // the result already carries — prints nothing.
164+ func (p *printer) finish(o outcome) {
165+ p.mu.Lock()
166+ defer p.mu.Unlock()
167+ if p.done {
168+ return
169+ }
170+ p.done = true
171+
172+ if p.format == formatText {
173+ // The answer and nothing else, which is what a script that captures
174+ // `claude -p` wants. A failure has no answer; its message goes to
175+ // stderr with the exit status.
176+ if o.Err == nil {
177+ fmt.Fprintln(p.w, o.Text)
178+ }
179+ return
180+ }
181+
182+ res := resultLine{
183+ Type: "result", Subtype: resultSuccess,
184+ DurationMS: p.now().Sub(p.start).Milliseconds(), DurationAPIMS: p.api.Milliseconds(),
185+ NumTurns: o.Turns, Result: o.Text, SessionID: p.session, Usage: o.Usage, UUID: p.newID(),
186+ }
187+ if cost, ok := o.Usage.Cost(p.model); ok {
188+ res.TotalCostUSD = cost
189+ }
190+ if o.Err != nil {
191+ res.IsError = true
192+ res.Subtype = resultExecution
193+ if errors.Is(o.Err, agent.ErrMaxTurns) {
194+ res.Subtype = resultMaxTurns
195+ }
196+ res.Errors = []string{o.Err.Error()}
197+ // A run that stopped part-way still hands back what the model last
198+ // said, as the line front end does; a run with nothing to hand back
199+ // says why, because `.result` is the field everybody prints.
200+ if res.Result == "" {
201+ res.Result = o.Err.Error()
202+ }
203+ }
204+ p.emit(res)
205+
206+ if p.array {
207+ p.write(p.records)
208+ }
209+ }
210+
211+ // emit prints one record, or holds it for the array.
212+ func (p *printer) emit(v any) {
213+ if p.array {
214+ p.records = append(p.records, v)
215+ return
216+ }
217+ p.write(v)
218+ }
219+
220+ // write prints one JSON value on a line of its own. HTML is not escaped,
221+ // because the reader is not a browser and Claude Code does not escape it:
222+ // a diff full of < would match nothing a script greps for.
223+ func (p *printer) write(v any) {
224+ var buf bytes.Buffer
225+ enc := json.NewEncoder(&buf)
226+ enc.SetEscapeHTML(false)
227+ if err := enc.Encode(v); err != nil {
228+ fmt.Fprintln(p.notes, "codey: encoding output:", err)
229+ return
230+ }
231+ p.w.Write(unescapeHTML(buf.Bytes()))
232+ }
233+
234+ // unescapeHTML undoes the escapes json.Marshal writes for <, > and & — a
235+ // backslash, a u, and the code point in four hex digits (003c, 003e, 0026).
236+ // The encoder's own setting does not reach them: a content block marshals
237+ // itself, with json.Marshal, before the encoder sees it. The scan steps over
238+ // every escape as a pair, so an escaped backslash followed by the letters
239+ // u003c is left alone.
240+ func unescapeHTML(b []byte) []byte {
241+ if !bytes.Contains(b, []byte(`\u00`)) {
242+ return b
243+ }
244+ out := make([]byte, 0, len(b))
245+ for i := 0; i < len(b); i++ {
246+ if b[i] != '\\' || i+1 == len(b) {
247+ out = append(out, b[i])
248+ continue
249+ }
250+ if b[i+1] == 'u' && i+6 <= len(b) {
251+ switch string(b[i+2 : i+6]) {
252+ case "003c":
253+ out, i = append(out, '<'), i+5
254+ continue
255+ case "003e":
256+ out, i = append(out, '>'), i+5
257+ continue
258+ case "0026":
259+ out, i = append(out, '&'), i+5
260+ continue
261+ }
262+ }
263+ out = append(out, b[i], b[i+1])
264+ i++
265+ }
266+ return out
267+ }
268+
269+ type initLine struct {
270+ Type string `json:"type"`
271+ Subtype string `json:"subtype"`
272+ CWD string `json:"cwd"`
273+ SessionID string `json:"session_id"`
274+ Tools []string `json:"tools"`
275+ MCPServers []mcpServer `json:"mcp_servers"`
276+ Model string `json:"model"`
277+ PermissionMode string `json:"permissionMode"`
278+ UUID string `json:"uuid"`
279+ }
280+
281+ type mcpServer struct {
282+ Name string `json:"name"`
283+ Status string `json:"status"`
284+ }
285+
286+ type systemLine struct {
287+ Type string `json:"type"`
288+ Subtype string `json:"subtype"`
289+ SessionID string `json:"session_id"`
290+ UUID string `json:"uuid"`
291+ }
292+
293+ // messageLine is an assistant or a user line. parent_tool_use_id is always
294+ // null: Codey's subagents report through a tool result, not by speaking in
295+ // the parent's stream, so nothing printed here belongs to a tool call.
296+ type messageLine struct {
297+ Type string `json:"type"`
298+ Message any `json:"message"`
299+ ParentToolUseID *string `json:"parent_tool_use_id"`
300+ SessionID string `json:"session_id"`
301+ UUID string `json:"uuid"`
302+ }
303+
304+ // apiMessage is an assistant message as the Messages API returns it.
305+ type apiMessage struct {
306+ ID string `json:"id"`
307+ Type string `json:"type"`
308+ Role string `json:"role"`
309+ Model string `json:"model"`
310+ Content []llm.ContentBlock `json:"content"`
311+ StopReason string `json:"stop_reason"`
312+ StopSequence *string `json:"stop_sequence"`
313+ Usage llm.Usage `json:"usage"`
314+ }
315+
316+ type userMessage struct {
317+ Role string `json:"role"`
318+ Content []llm.ContentBlock `json:"content"`
319+ }
320+
321+ type resultLine struct {
322+ Type string `json:"type"`
323+ Subtype string `json:"subtype"`
324+ IsError bool `json:"is_error"`
325+ DurationMS int64 `json:"duration_ms"`
326+ DurationAPIMS int64 `json:"duration_api_ms"`
327+ NumTurns int `json:"num_turns"`
328+ Result string `json:"result"`
329+ SessionID string `json:"session_id"`
330+ TotalCostUSD float64 `json:"total_cost_usd"`
331+ Usage llm.Usage `json:"usage"`
332+ Errors []string `json:"errors,omitempty"`
333+ UUID string `json:"uuid"`
334+ }
335+
336+ // mcpServers names the servers whose tools are on offer. A server that failed
337+ // to start has none, and is left out rather than reported in a state this
338+ // list cannot tell apart from not configured.
339+ func mcpServers(tools []string) []mcpServer {
340+ seen := map[string]bool{}
341+ for _, t := range tools {
342+ rest, ok := strings.CutPrefix(t, "mcp__")
343+ if !ok {
344+ continue
345+ }
346+ if name, _, ok := strings.Cut(rest, "__"); ok {
347+ seen[name] = true
348+ }
349+ }
350+ out := []mcpServer{}
351+ for name := range seen {
352+ out = append(out, mcpServer{Name: name, Status: "connected"})
353+ }
354+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
355+ return out
356+ }
357+
358+ // nonNil keeps an empty content list printing as [] rather than null.
359+ func nonNil(blocks []llm.ContentBlock) []llm.ContentBlock {
360+ if blocks == nil {
361+ return []llm.ContentBlock{}
362+ }
363+ return blocks
364+ }
365+
366+ // uuid4 is a random version-4 UUID, which is what Claude Code stamps on every
367+ // line. Nothing reads them back; they are there so a reader that keys lines by
368+ // uuid has something to key on.
369+ func uuid4() string {
370+ var b [16]byte
371+ rand.Read(b[:])
372+ b[6] = b[6]&0x0f | 0x40
373+ b[8] = b[8]&0x3f | 0x80
374+ return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
375+ }
376+
377+ // printOnce is once for a Claude-shaped run: the prompt, then the result in
378+ // the chosen format. The usage and turns are this run's alone, so a resumed
379+ // session reports what this invocation cost rather than everything before it.
380+ func printOnce(ctx context.Context, a *agent.Agent, p *printer, sessionDir, id, root string, client *llm.Client, prompt string) error {
381+ before, turnsBefore := a.State().Usage()
382+
383+ turnCtx, endTurn := beginTurn(ctx)
384+ text, runErr := a.Run(turnCtx, prompt)
385+ endTurn()
386+
387+ after, turnsAfter := a.State().Usage()
388+ if err := a.Save(sessionDir, id, root, "", client.Model()); err != nil {
389+ fmt.Fprintln(os.Stderr, "codey: saving session:", err)
390+ }
391+ if errors.Is(runErr, context.Canceled) {
392+ // Unlike the line front end, an interrupted -p run is a failure: the
393+ // script that started it did not get its answer. The session is saved
394+ // all the same, so -c picks it up.
395+ runErr = errors.New("interrupted — the session is saved; continue it with -c")
396+ }
397+ p.finish(outcome{Text: text, Err: runErr, Turns: turnsAfter - turnsBefore, Usage: usageSince(after, before)})
398+ return runErr
399+ }
400+
401+ func usageSince(after, before llm.Usage) llm.Usage {
402+ return llm.Usage{
403+ InputTokens: after.InputTokens - before.InputTokens,
404+ OutputTokens: after.OutputTokens - before.OutputTokens,
405+ CacheReadInputTokens: after.CacheReadInputTokens - before.CacheReadInputTokens,
406+ CacheCreationInputTokens: after.CacheCreationInputTokens - before.CacheCreationInputTokens,
407+ }
408+ }
+329−0cmd/codey/printout_test.goaddedView File
@@ -0,0 +1,329 @@
1+ package main
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "net/http/httptest"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/johannSo/codey/internal/agent"
19+ "github.com/johannSo/codey/internal/approval"
20+ "github.com/johannSo/codey/internal/llm"
21+ "github.com/johannSo/codey/internal/tools"
22+ )
23+
24+ // fixedPrinter is a printer on a clock the test moves and with uuids that
25+ // count, so its output can be compared byte for byte.
26+ func fixedPrinter(format string, verbose bool) (*printer, *bytes.Buffer, *bytes.Buffer, *time.Time) {
27+ var out, notes bytes.Buffer
28+ p := newPrinter(&out, &notes, format, verbose)
29+ clock := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
30+ p.now = func() time.Time { return clock }
31+ n := 0
32+ p.newID = func() string { n++; return fmt.Sprintf("uuid-%d", n) }
33+ p.session = "20261002T120000Z"
34+ return p, &out, &notes, &clock
35+ }
36+
37+ // script plays one tool round trip into a printer, the way the loop would.
38+ func script(p *printer, clock *time.Time) {
39+ tick := func(d time.Duration) { *clock = clock.Add(d) }
40+ tick(5 * time.Millisecond)
41+ p.observe(agent.Event{Kind: agent.EventTurnStart, Turn: 1})
42+ tick(700 * time.Millisecond)
43+ p.observe(agent.Event{Kind: agent.EventUsage, Turn: 1})
44+ p.observe(agent.Event{Kind: agent.EventMessage, Turn: 1, Response: &llm.Response{
45+ ID: "msg_1", Model: "claude-opus-4-1", StopReason: "tool_use",
46+ Content: []llm.ContentBlock{
47+ llm.TextBlock("Looking <here>."),
48+ {Type: llm.BlockToolUse, ID: "tu_1", Name: "bash", Input: json.RawMessage(`{"command":"ls"}`)},
49+ },
50+ Usage: llm.Usage{InputTokens: 10, OutputTokens: 5},
51+ }})
52+ p.observe(agent.Event{Kind: agent.EventText, Turn: 1, Text: "ignored: the message line has it"})
53+ p.observe(agent.Event{Kind: agent.EventNotice, Text: "the window is full"})
54+ results := llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.ToolResultBlock("tu_1", "a.go\nb.go", false)}}
55+ p.observe(agent.Event{Kind: agent.EventMessage, Turn: 1, Message: &results})
56+ p.observe(agent.Event{Kind: agent.EventCompact})
57+ p.observe(agent.Event{Kind: agent.EventTurnStart, Turn: 2})
58+ tick(300 * time.Millisecond)
59+ p.observe(agent.Event{Kind: agent.EventUsage, Turn: 2})
60+ p.observe(agent.Event{Kind: agent.EventMessage, Turn: 2, Response: &llm.Response{
61+ ID: "msg_2", StopReason: "end_turn",
62+ Content: []llm.ContentBlock{llm.TextBlock("Two files.")},
63+ Usage: llm.Usage{InputTokens: 20, OutputTokens: 3, CacheReadInputTokens: 8},
64+ }})
65+ tick(50 * time.Millisecond)
66+ }
67+
68+ const (
69+ wantInit = `{"type":"system","subtype":"init","cwd":"/work","session_id":"20261002T120000Z","tools":["bash","mcp__db__query","read_file"],"mcp_servers":[{"name":"db","status":"connected"}],"model":"claude-opus-4-1","permissionMode":"default","uuid":"uuid-1"}`
70+ wantAssistant = `{"type":"assistant","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"text":"Looking <here>.","type":"text"},{"id":"tu_1","input":{"command":"ls"},"name":"bash","type":"tool_use"}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"parent_tool_use_id":null,"session_id":"20261002T120000Z","uuid":"uuid-2"}`
71+ wantUser = `{"type":"user","message":{"role":"user","content":[{"content":"a.go\nb.go","tool_use_id":"tu_1","type":"tool_result"}]},"parent_tool_use_id":null,"session_id":"20261002T120000Z","uuid":"uuid-3"}`
72+ wantCompact = `{"type":"system","subtype":"compact_boundary","session_id":"20261002T120000Z","uuid":"uuid-4"}`
73+ // The second response named no model, so the session's stands in.
74+ wantAssistant2 = `{"type":"assistant","message":{"id":"msg_2","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"text":"Two files.","type":"text"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":3,"cache_read_input_tokens":8,"cache_creation_input_tokens":0}},"parent_tool_use_id":null,"session_id":"20261002T120000Z","uuid":"uuid-5"}`
75+ )
76+
77+ func resultFor(usage llm.Usage, model string) float64 {
78+ c, _ := usage.Cost(model)
79+ return c
80+ }
81+
82+ func TestStreamJSONShape(t *testing.T) {
83+ p, out, notes, clock := fixedPrinter(formatStreamJSON, false)
84+ p.begin("/work", "claude-opus-4-1", "default", []string{"bash", "mcp__db__query", "read_file"})
85+ script(p, clock)
86+ usage := llm.Usage{InputTokens: 30, OutputTokens: 8, CacheReadInputTokens: 8}
87+ p.finish(outcome{Text: "Two files.", Turns: 2, Usage: usage})
88+
89+ cost, _ := json.Marshal(resultFor(usage, "claude-opus-4-1"))
90+ wantResult := `{"type":"result","subtype":"success","is_error":false,"duration_ms":1055,"duration_api_ms":1000,"num_turns":2,"result":"Two files.","session_id":"20261002T120000Z","total_cost_usd":` + string(cost) + `,"usage":{"input_tokens":30,"output_tokens":8,"cache_read_input_tokens":8,"cache_creation_input_tokens":0},"uuid":"uuid-6"}`
91+
92+ want := strings.Join([]string{wantInit, wantAssistant, wantUser, wantCompact, wantAssistant2, wantResult}, "\n") + "\n"
93+ if got := out.String(); got != want {
94+ t.Errorf("stream-json:\n got %s\nwant %s", got, want)
95+ }
96+ if !strings.Contains(notes.String(), "codey: the window is full") {
97+ t.Errorf("notices go to stderr, got %q", notes.String())
98+ }
99+ // Every line is JSON a line-by-line reader can parse on its own.
100+ for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
101+ if !json.Valid([]byte(line)) {
102+ t.Errorf("not JSON: %s", line)
103+ }
104+ }
105+ }
106+
107+ func TestJSONIsOneResultObject(t *testing.T) {
108+ p, out, _, clock := fixedPrinter(formatJSON, false)
109+ p.begin("/work", "claude-opus-4-1", "default", nil)
110+ script(p, clock)
111+ p.finish(outcome{Text: "Two files.", Turns: 2})
112+
113+ var got map[string]any
114+ if err := json.Unmarshal(out.Bytes(), &got); err != nil {
115+ t.Fatalf("json mode should print one object: %v\n%s", err, out.String())
116+ }
117+ if strings.Count(strings.TrimSpace(out.String()), "\n") != 0 {
118+ t.Errorf("one line only, got\n%s", out.String())
119+ }
120+ for key, want := range map[string]any{
121+ "type": "result", "subtype": "success", "is_error": false, "result": "Two files.",
122+ "session_id": "20261002T120000Z", "num_turns": 2.0, "duration_ms": 1055.0, "duration_api_ms": 1000.0,
123+ } {
124+ if got[key] != want {
125+ t.Errorf("%s = %v, want %v", key, got[key], want)
126+ }
127+ }
128+ usage, _ := got["usage"].(map[string]any)
129+ for _, k := range []string{"input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"} {
130+ if _, ok := usage[k]; !ok {
131+ t.Errorf("usage has no %s: %v", k, usage)
132+ }
133+ }
134+ if _, ok := got["total_cost_usd"].(float64); !ok {
135+ t.Errorf("total_cost_usd should be a number, got %v", got["total_cost_usd"])
136+ }
137+ if _, ok := got["errors"]; ok {
138+ t.Error("a success carries no errors field")
139+ }
140+ }
141+
142+ func TestJSONVerboseIsTheStreamAsAnArray(t *testing.T) {
143+ p, out, _, clock := fixedPrinter(formatJSON, true)
144+ p.begin("/work", "claude-opus-4-1", "default", []string{"bash", "mcp__db__query", "read_file"})
145+ script(p, clock)
146+ p.finish(outcome{Text: "Two files.", Turns: 2})
147+
148+ var lines []map[string]any
149+ if err := json.Unmarshal(out.Bytes(), &lines); err != nil {
150+ t.Fatalf("json --verbose prints an array: %v\n%s", err, out.String())
151+ }
152+ var types []string
153+ for _, l := range lines {
154+ types = append(types, l["type"].(string))
155+ }
156+ if strings.Join(types, ",") != "system,assistant,user,system,assistant,result" {
157+ t.Errorf("types = %v", types)
158+ }
159+ }
160+
161+ func TestResultSubtypes(t *testing.T) {
162+ cases := []struct {
163+ name string
164+ o outcome
165+ subtype string
166+ wantResult string
167+ wantIsError bool
168+ }{
169+ {"max turns keeps the last text", outcome{Text: "half done", Err: fmt.Errorf("%w: stopped", agent.ErrMaxTurns), Turns: 3}, resultMaxTurns, "half done", true},
170+ {"an error with no text says why", outcome{Err: errors.New("no credentials")}, resultExecution, "no credentials", true},
171+ {"success", outcome{Text: "ok"}, resultSuccess, "ok", false},
172+ }
173+ for _, c := range cases {
174+ t.Run(c.name, func(t *testing.T) {
175+ p, out, _, _ := fixedPrinter(formatJSON, false)
176+ p.finish(c.o)
177+ var got struct {
178+ Subtype string `json:"subtype"`
179+ IsError bool `json:"is_error"`
180+ Result string `json:"result"`
181+ Errors []string `json:"errors"`
182+ }
183+ if err := json.Unmarshal(out.Bytes(), &got); err != nil {
184+ t.Fatal(err)
185+ }
186+ if got.Subtype != c.subtype || got.IsError != c.wantIsError || got.Result != c.wantResult {
187+ t.Errorf("got %+v", got)
188+ }
189+ if c.o.Err != nil && (len(got.Errors) != 1 || got.Errors[0] != c.o.Err.Error()) {
190+ t.Errorf("errors = %q, want the message", got.Errors)
191+ }
192+ })
193+ }
194+ }
195+
196+ func TestFinishPrintsOnce(t *testing.T) {
197+ p, out, _, _ := fixedPrinter(formatStreamJSON, false)
198+ p.finish(outcome{Err: errors.New("boom")})
199+ claudeOut = p
200+ defer func() { claudeOut = nil }()
201+ reportFailure(errors.New("boom"))
202+ if n := strings.Count(out.String(), `"type":"result"`); n != 1 {
203+ t.Errorf("printed %d results, want 1", n)
204+ }
205+ }
206+
207+ func TestTextFormatIsTheAnswerOnly(t *testing.T) {
208+ p, out, _, clock := fixedPrinter(formatText, false)
209+ p.begin("/work", "m", "default", []string{"bash"})
210+ script(p, clock)
211+ p.finish(outcome{Text: "Two files."})
212+ if out.String() != "Two files.\n" {
213+ t.Errorf("text = %q", out.String())
214+ }
215+
216+ p, out, _, _ = fixedPrinter(formatText, false)
217+ p.finish(outcome{Err: errors.New("boom")})
218+ if out.Len() != 0 {
219+ t.Errorf("a failure prints nothing to stdout in text mode, got %q", out.String())
220+ }
221+ }
222+
223+ func TestUnescapeHTMLLeavesEscapedBackslashesAlone(t *testing.T) {
224+ // The escapes are put together at run time so that nothing between this
225+ // file and the compiler can mistake them for the characters they stand for.
226+ esc := func(hex string) string { return `\` + "u00" + hex }
227+ lt, amp, gt := esc("3c"), esc("26"), esc("3e")
228+ in := []byte(`{"a":"x ` + lt + ` y ` + amp + ` z ` + gt + `","b":"\\` + lt[1:] + ` stays","c":"\\` + lt + `"}`)
229+ want := `{"a":"x < y & z >","b":"\\` + lt[1:] + ` stays","c":"\\<"}`
230+ got := unescapeHTML(in)
231+ if string(got) != want {
232+ t.Errorf("got %s\nwant %s", got, want)
233+ }
234+ var v map[string]string
235+ if err := json.Unmarshal(got, &v); err != nil || v["b"] != lt+" stays" || v["c"] != `\<` {
236+ t.Errorf("the result should decode to the same strings: %v %q", err, v)
237+ }
238+ // And the encoder's own output, end to end.
239+ var out bytes.Buffer
240+ p := newPrinter(&out, io.Discard, formatStreamJSON, false)
241+ p.write(llm.TextBlock("a<b && c>d"))
242+ if strings.TrimSpace(out.String()) != `{"text":"a<b && c>d","type":"text"}` {
243+ t.Errorf("a content block should print with its HTML unescaped, got %s", out.String())
244+ }
245+ }
246+
247+ func TestUUID4(t *testing.T) {
248+ id := uuid4()
249+ if len(id) != 36 || id[14] != '4' || !strings.ContainsRune("89ab", rune(id[19])) {
250+ t.Errorf("not a version-4 uuid: %s", id)
251+ }
252+ }
253+
254+ // A real run through the loop, against a fake Messages API: the lines on
255+ // stdout are the agent's own messages, a refused tool call included, and the
256+ // session is saved where -c will find it.
257+ func TestPrintOnceStreamsARealRun(t *testing.T) {
258+ responses := []string{
259+ `{"id":"msg_a","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"type":"text","text":"Checking."},{"type":"tool_use","id":"tu_1","name":"bash","input":{"command":"rm -rf build"}}],"stop_reason":"tool_use","usage":{"input_tokens":12,"output_tokens":4}}`,
260+ `{"id":"msg_b","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"type":"text","text":"Not allowed, so I stopped."}],"stop_reason":"end_turn","usage":{"input_tokens":20,"output_tokens":6}}`,
261+ }
262+ calls := 0
263+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
264+ io.ReadAll(r.Body)
265+ if calls >= len(responses) {
266+ w.WriteHeader(http.StatusInternalServerError)
267+ return
268+ }
269+ io.WriteString(w, responses[calls])
270+ calls++
271+ }))
272+ defer srv.Close()
273+
274+ root := t.TempDir()
275+ client := llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k"), llm.WithModel("claude-opus-4-1"))
276+ state := agent.NewState()
277+ reg := tools.NewRegistry(nil).Register(tools.NewBash(root))
278+ a := agent.New(client, reg, state, nil, agent.Options{})
279+ policy := approval.New(nil)
280+ policy.Rules = approval.RuleList{Deny: []approval.Rule{mustParse(t, "Bash(rm:*)")}}
281+ a.Approve = policy
282+
283+ p, out, _, _ := fixedPrinter(formatStreamJSON, false)
284+ a.Observe = p.observe
285+ p.begin(root, client.Model(), "default", []string{"bash"})
286+ sessionDir := filepath.Join(root, agent.SessionDir)
287+ if err := printOnce(context.Background(), a, p, sessionDir, "s1", root, client, "clean up"); err != nil {
288+ t.Fatalf("printOnce: %v", err)
289+ }
290+
291+ var lines []map[string]any
292+ for _, l := range strings.Split(strings.TrimSpace(out.String()), "\n") {
293+ var m map[string]any
294+ if err := json.Unmarshal([]byte(l), &m); err != nil {
295+ t.Fatalf("not JSON: %s", l)
296+ }
297+ lines = append(lines, m)
298+ }
299+ var types []string
300+ for _, l := range lines {
301+ types = append(types, l["type"].(string))
302+ }
303+ if strings.Join(types, ",") != "system,assistant,user,assistant,result" {
304+ t.Fatalf("types = %v\n%s", types, out.String())
305+ }
306+ user := lines[2]["message"].(map[string]any)["content"].([]any)[0].(map[string]any)
307+ if user["type"] != "tool_result" || user["is_error"] != true || !strings.Contains(user["content"].(string), "Bash(rm:*)") {
308+ t.Errorf("the refused call should come back as an error result naming the rule: %v", user)
309+ }
310+ res := lines[4]
311+ if res["result"] != "Not allowed, so I stopped." || res["num_turns"] != 2.0 || res["is_error"] != false {
312+ t.Errorf("result = %v", res)
313+ }
314+ if u := res["usage"].(map[string]any); u["input_tokens"] != 32.0 || u["output_tokens"] != 10.0 {
315+ t.Errorf("usage = %v, want the two responses summed", u)
316+ }
317+ if _, err := os.Stat(filepath.Join(sessionDir, "s1.json")); err != nil {
318+ t.Errorf("the session should be saved for -c: %v", err)
319+ }
320+ }
321+
322+ func mustParse(t *testing.T, s string) approval.Rule {
323+ t.Helper()
324+ r, err := approval.ParseRule(s)
325+ if err != nil {
326+ t.Fatal(err)
327+ }
328+ return r
329+ }
+1−1go.modView File
@@ -14,6 +14,7 @@ require (
1414 github.com/invopop/jsonschema v0.14.0
1515 github.com/muesli/termenv v0.16.0
1616 golang.org/x/net v0.38.0
17+ golang.org/x/term v0.45.0
1718 gopkg.in/yaml.v3 v3.0.1
1819 mvdan.cc/sh/v3 v3.14.1
1920 )
@@ -48,6 +49,5 @@ require (
4849 github.com/yuin/goldmark-emoji v1.0.6 // indirect
4950 go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
5051 golang.org/x/sys v0.47.0 // indirect
51- golang.org/x/term v0.45.0 // indirect
5252 golang.org/x/text v0.30.0 // indirect
5353 )
+25−1internal/agent/loop.goView File
@@ -304,6 +304,11 @@ type Options struct {
304304 // the model is done, the window is full, or the context is cancelled.
305305 MaxTurns int
306306 System string
307+ // SystemAppend is added to the end of the system prompt, whichever one
308+ // that is — `--append-system-prompt`, for a script that wants to add a
309+ // house rule without replacing the instructions on how to work. It is
310+ // constant for the session, so it costs no cache hit.
311+ SystemAppend string
307312 // Effort maps to output_config.effort. Empty means the API default (high).
308313 Effort string
309314 // ContextWindow, CompactAt and CompactFloor control when the conversation
@@ -392,6 +397,14 @@ const (
392397 // EventResumed when it is let go again.
393398 EventPaused EventKind = "paused"
394399 EventResumed EventKind = "resumed"
400+ // EventMessage is a message joining the history: each complete response
401+ // from the model, with Response set, and each batch of tool results that
402+ // answers one, with Message set. It is for a front end that reports the
403+ // conversation itself rather than drawing it — `-p --output-format
404+ // stream-json` prints one line per message — and everything else can
405+ // ignore it, because the same content has already arrived as the events
406+ // above.
407+ EventMessage EventKind = "message"
395408 )
396409
397410 // Event is what the loop reports to an observer. It carries plain data only —
@@ -411,6 +424,11 @@ type Event struct {
411424 Result string
412425 IsError bool
413426 Usage llm.Usage
427+ // Response and Message are EventMessage's: the model's whole response,
428+ // or the user message of tool results the loop sent back. Exactly one is
429+ // set.
430+ Response *llm.Response
431+ Message *llm.Message
414432 }
415433
416434 // Approver decides whether a tool call may proceed. It is an interface here so
@@ -563,6 +581,9 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log
563581 opts.System += opts.Instructions
564582 opts.System += skillListing(opts.Skills)
565583 opts.System += modelListing(opts.Models)
584+ if opts.SystemAppend != "" {
585+ opts.System += "\n\n" + opts.SystemAppend
586+ }
566587 return &Agent{client: client, tools: reg, state: state, log: log, opts: opts}
567588 }
568589
@@ -694,6 +715,7 @@ func (a *Agent) drive(ctx context.Context) (string, error) {
694715 // The assistant message goes into history verbatim, thinking blocks and
695716 // all: their signatures must come back unchanged on the next request.
696717 a.state.Append(resp.Message())
718+ a.emit(Event{Kind: EventMessage, Turn: turn, Response: resp})
697719
698720 // Hosted tool calls are shown here, once the response is complete,
699721 // on both the streaming and the plain path. They never reach dispatch
@@ -735,7 +757,9 @@ func (a *Agent) drive(ctx context.Context) (string, error) {
735757 // message. Splitting them across messages is an API error, and even
736758 // where it is not it teaches the model to stop calling tools in
737759 // parallel.
738- a.state.Append(llm.Message{Role: llm.RoleUser, Content: results})
760+ answer := llm.Message{Role: llm.RoleUser, Content: results}
761+ a.state.Append(answer)
762+ a.emit(Event{Kind: EventMessage, Turn: turn, Message: &answer})
739763
740764 case llm.StopPauseTurn:
741765 // A server-side tool needs another round trip; resend as-is. Hosted
+47−0internal/agent/loop_test.goView File
@@ -1146,3 +1146,50 @@ func TestRedactedReasoningIsNotReported(t *testing.T) {
11461146 t.Error("the redacted block was dropped from the conversation")
11471147 }
11481148 }
1149+
1150+ // Every message that joins the history is reported whole, in order: the
1151+ // model's response with its id and usage, then the tool results that answer
1152+ // it. A front end printing the conversation as JSON reads nothing else.
1153+ func TestEveryMessageIsReportedAsItJoinsTheHistory(t *testing.T) {
1154+ root := t.TempDir()
1155+ os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644)
1156+ f := newFakeAPI(t,
1157+ msg("tool_use", textBlock("Let me look."), toolUseBlock("tu_1", "read_file", `{"path":"main.go"}`)),
1158+ msg("end_turn", textBlock("It declares package main.")),
1159+ )
1160+ a := newTestAgent(t, f, root, &bytes.Buffer{})
1161+ var got []Event
1162+ a.Observe = func(e Event) {
1163+ if e.Kind == EventMessage {
1164+ got = append(got, e)
1165+ }
1166+ }
1167+ if _, err := a.Run(context.Background(), "what does main.go do?"); err != nil {
1168+ t.Fatalf("Run: %v", err)
1169+ }
1170+ if len(got) != 3 {
1171+ t.Fatalf("got %d message events, want 3 (response, results, response)", len(got))
1172+ }
1173+ if r := got[0].Response; r == nil || r.ID != "msg" || len(r.ToolUses()) != 1 || r.Usage.InputTokens != 100 || got[0].Message != nil {
1174+ t.Errorf("first event should be the whole first response, got %+v", got[0])
1175+ }
1176+ if m := got[1].Message; m == nil || m.Role != llm.RoleUser || len(m.Content) != 1 || m.Content[0].ToolUseID != "tu_1" || got[1].Response != nil {
1177+ t.Errorf("second event should be the tool results, got %+v", got[1])
1178+ }
1179+ if r := got[2].Response; r == nil || r.Text() != "It declares package main." {
1180+ t.Errorf("third event should be the answer, got %+v", got[2])
1181+ }
1182+ }
1183+
1184+ func TestSystemAppendGoesAtTheEnd(t *testing.T) {
1185+ a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{SystemAppend: "Answer in French."})
1186+ blocks := a.systemBlocks()
1187+ text := blocks[len(blocks)-1].Text
1188+ if !strings.HasPrefix(text, defaultSystem) || !strings.HasSuffix(text, "\n\nAnswer in French.") {
1189+ t.Errorf("the appended text should follow the default prompt, got the tail %q", text[len(text)-40:])
1190+ }
1191+ replaced := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{System: "Be terse.", SystemAppend: "Answer in French."})
1192+ if got := replaced.systemBlocks()[0].Text; got != "Be terse.\n\nAnswer in French." {
1193+ t.Errorf("an append goes after a replaced prompt too, got %q", got)
1194+ }
1195+ }
+1005−0internal/approval/rules.goaddedView File
@@ -0,0 +1,1005 @@
1+ package approval
2+
3+ // Permission rules in Claude Code's syntax.
4+ //
5+ // A script written for `claude -p` says what the agent may do with
6+ // --allowedTools and --disallowedTools, and a repository says the same thing in
7+ // .claude/settings.json under permissions.allow and permissions.deny. Both are
8+ // lists of rules in one grammar, and this file is the one reading of it, so
9+ // that a rule means the same thing wherever Codey found it.
10+ //
11+ // The API, for anything that has a list of rule strings in hand:
12+ //
13+ // rule, err := approval.ParseRule("Bash(npm run test:*)")
14+ // rule.Matches("bash", input) // the rule covers the whole call
15+ // rule.Touches("bash", input) // the rule covers some part of the call
16+ //
17+ // set := approval.RuleList{Allow: allowRules, Deny: denyRules}
18+ // ruling := set.Check("bash", input) // VerdictAllow, VerdictDeny or NoVerdict
19+ // policy.Rules = set // a RuleSet, so the policy consults it
20+ //
21+ // SplitRules cuts a flag value or a settings entry into rule strings, and
22+ // ParseRules parses a list of them, keeping the good ones and reporting the
23+ // rest. An error from ParseRule is a reason to warn, not to stop: a rule
24+ // naming a tool Codey does not have is a rule about nothing here.
25+ //
26+ // Two readings of "matches" exist because allowing and denying need opposite
27+ // kinds of caution. A compound shell command is allowed only when every
28+ // simple command in it is covered by some allow rule, so `git status; rm -rf
29+ // ~` does not ride in on Bash(git status:*). It is denied when any one of them
30+ // hits a deny rule. A command the shell analysis cannot see through — command
31+ // substitution, an inline interpreter script, a parse error — is never allowed
32+ // by a rule, and is denied if anything that can be read out of it matches.
33+ //
34+ // Deny rules are a guard rail, not a sandbox, for the reason shell.go gives:
35+ // a determined command can always be spelled so that no string match sees it.
36+ // They catch the command the model would ordinarily write.
37+
38+ import (
39+ "encoding/json"
40+ "errors"
41+ "fmt"
42+ "net/url"
43+ "os"
44+ "path"
45+ "path/filepath"
46+ "strings"
47+ "unicode"
48+
49+ "mvdan.cc/sh/v3/syntax"
50+ )
51+
52+ // ErrUnknownTool is wrapped by ParseRule when a rule names a tool Codey has no
53+ // counterpart for. Callers warn and carry on.
54+ var ErrUnknownTool = errors.New("unknown tool")
55+
56+ // specKind is what the text inside a rule's parentheses is about.
57+ type specKind int
58+
59+ const (
60+ specNone specKind = iota // the rule takes no argument
61+ specBash // a command, or a command prefix
62+ specPath // a gitignore-style path pattern
63+ specDomain // domain:host, for web_fetch
64+ )
65+
66+ // ccTool is how one Claude Code tool name lands in Codey.
67+ type ccTool struct {
68+ // whole is what the bare name covers; scoped is what a rule with an
69+ // argument covers. They differ for Read, whose path rules Claude Code
70+ // applies to its search tools as well, and they are the same elsewhere.
71+ whole, scoped []string
72+ spec specKind
73+ }
74+
75+ // ccTools maps Claude Code's tool names onto Codey's.
76+ //
77+ // Edit covers write_file as well as edit_file because Claude Code says its
78+ // Edit rules apply to every built-in tool that edits files, and Write is one;
79+ // a script that disallows Edit wants no edits, whichever tool would make them.
80+ // MultiEdit is Claude Code's several-replacements edit, which edit_file is.
81+ var ccTools = map[string]ccTool{
82+ "Bash": {whole: []string{"bash"}, spec: specBash},
83+ "Read": {whole: []string{"read_file"}, scoped: []string{"read_file", "glob", "grep"}, spec: specPath},
84+ "Edit": {whole: []string{"edit_file", "write_file"}, spec: specPath},
85+ "MultiEdit": {whole: []string{"edit_file"}, spec: specPath},
86+ "Write": {whole: []string{"write_file"}, spec: specPath},
87+ "Glob": {whole: []string{"glob"}},
88+ "Grep": {whole: []string{"grep"}},
89+ "WebFetch": {whole: []string{"web_fetch"}, spec: specDomain},
90+ "WebSearch": {whole: []string{"web_search"}},
91+ "TodoWrite": {whole: []string{"todo_write"}},
92+ "Task": {whole: []string{"spawn_agent"}},
93+ "Agent": {whole: []string{"spawn_agent"}},
94+ "BashOutput": {whole: []string{"process_output"}},
95+ "KillShell": {whole: []string{"process_kill"}},
96+ "KillBash": {whole: []string{"process_kill"}},
97+ "Skill": {whole: []string{"skill"}},
98+ "AskUserQuestion": {whole: []string{"ask_user"}},
99+ }
100+
101+ // codeyTools are Codey's own names, accepted as they are, so a rule can name a
102+ // tool Claude Code has no word for. The ones that take an argument take the
103+ // same argument as their Claude Code counterpart.
104+ var codeyTools = map[string]specKind{
105+ "bash": specBash, "read_file": specPath, "edit_file": specPath, "write_file": specPath,
106+ "glob": specNone, "grep": specNone, "web_fetch": specDomain, "web_search": specNone,
107+ "todo_write": specNone, "process_output": specNone, "process_kill": specNone,
108+ "skill": specNone, "ask_user": specNone, "context_status": specNone,
109+ "spawn_agent": specNone, "agent_wait": specNone, "lsp": specNone,
110+ }
111+
112+ // mcpPrefix is how an MCP tool's registered name begins, here as in Claude Code.
113+ const mcpPrefix = "mcp__"
114+
115+ // Rule is one parsed permission rule.
116+ type Rule struct {
117+ // Raw is the rule as it was written, for messages.
118+ Raw string
119+ // Tools are the Codey tool names the rule is about. For an MCP rule it
120+ // holds the one name, which is a whole server when it has no tool part.
121+ Tools []string
122+ // Specifier is the text inside the parentheses, empty for a rule about the
123+ // whole tool.
124+ Specifier string
125+ // Root is the directory relative path patterns are read against — the
126+ // working root for a flag, the project for a settings file. Empty means
127+ // paths are compared as the tool call gave them.
128+ Root string
129+ // Home is what a leading ~/ in a path pattern stands for. Empty means the
130+ // current user's home directory.
131+ Home string
132+
133+ spec specKind
134+ mcp bool
135+ }
136+
137+ // SplitRules cuts a list of rules into its items. Claude Code accepts them
138+ // separated by commas or by spaces, and a rule's argument may itself contain
139+ // either — Bash(git log --oneline, -n 5) is one rule — so separators count only
140+ // outside parentheses.
141+ func SplitRules(list string) []string {
142+ var (
143+ out []string
144+ cur strings.Builder
145+ depth int
146+ )
147+ flush := func() {
148+ if s := strings.TrimSpace(cur.String()); s != "" {
149+ out = append(out, s)
150+ }
151+ cur.Reset()
152+ }
153+ for _, r := range list {
154+ switch {
155+ case r == '(':
156+ depth++
157+ case r == ')' && depth > 0:
158+ depth--
159+ case depth == 0 && (r == ',' || unicode.IsSpace(r)):
160+ flush()
161+ continue
162+ }
163+ cur.WriteRune(r)
164+ }
165+ flush()
166+ return out
167+ }
168+
169+ // ParseRules parses every rule in the list, keeping the good ones. The errors
170+ // are for a warning: one rule that names a tool Codey does not have is no
171+ // reason to drop the others.
172+ func ParseRules(list []string) ([]Rule, []error) {
173+ var (
174+ rules []Rule
175+ errs []error
176+ )
177+ for _, s := range list {
178+ r, err := ParseRule(s)
179+ if err != nil {
180+ errs = append(errs, err)
181+ continue
182+ }
183+ rules = append(rules, r)
184+ }
185+ return rules, errs
186+ }
187+
188+ // ParseRule reads one rule: a tool name, optionally followed by an argument in
189+ // parentheses — Bash, Bash(git status), Bash(npm run test:*), Read(./src/**),
190+ // WebFetch(domain:example.com), mcp__github, mcp__github__create_issue.
191+ func ParseRule(s string) (Rule, error) {
192+ raw := strings.TrimSpace(s)
193+ name, spec := raw, ""
194+ if i := strings.IndexByte(raw, '('); i >= 0 {
195+ if !strings.HasSuffix(raw, ")") {
196+ return Rule{}, fmt.Errorf("rule %q: no closing parenthesis", raw)
197+ }
198+ name, spec = raw[:i], strings.TrimSpace(raw[i+1:len(raw)-1])
199+ }
200+ if name == "" {
201+ return Rule{}, fmt.Errorf("rule %q: no tool name", raw)
202+ }
203+ // An argument of * is the whole tool written longhand.
204+ if spec == "*" {
205+ spec = ""
206+ }
207+ r := Rule{Raw: raw, Specifier: spec}
208+
209+ switch {
210+ case strings.HasPrefix(name, mcpPrefix):
211+ if spec != "" {
212+ return Rule{}, fmt.Errorf("rule %q: an MCP rule takes no argument", raw)
213+ }
214+ // mcp__server__* is mcp__server: every tool the server has.
215+ name = strings.TrimSuffix(name, "__*")
216+ if name == strings.TrimSuffix(mcpPrefix, "__") || name == mcpPrefix {
217+ return Rule{}, fmt.Errorf("rule %q: no server named", raw)
218+ }
219+ r.Tools, r.mcp = []string{name}, true
220+ return r, nil
221+ }
222+
223+ if t, ok := ccTools[name]; ok {
224+ r.Tools, r.spec = t.whole, t.spec
225+ if spec != "" && t.scoped != nil {
226+ r.Tools = t.scoped
227+ }
228+ } else if kind, ok := codeyTools[name]; ok {
229+ r.Tools, r.spec = []string{name}, kind
230+ } else {
231+ return Rule{}, fmt.Errorf("rule %q: %w %q", raw, ErrUnknownTool, name)
232+ }
233+
234+ if spec == "" {
235+ return r, nil
236+ }
237+ switch r.spec {
238+ case specNone:
239+ return Rule{}, fmt.Errorf("rule %q: %s takes no argument", raw, name)
240+ case specDomain:
241+ host, ok := strings.CutPrefix(spec, "domain:")
242+ if !ok || host == "" {
243+ return Rule{}, fmt.Errorf("rule %q: expected %s(domain:example.com)", raw, name)
244+ }
245+ r.Specifier = strings.ToLower(host)
246+ }
247+ return r, nil
248+ }
249+
250+ // String is the rule as it was written.
251+ func (r Rule) String() string { return r.Raw }
252+
253+ // WholeTool reports whether the rule is about every call to its tools, which
254+ // for a deny rule means the tools need not be offered at all.
255+ func (r Rule) WholeTool() bool { return r.Specifier == "" }
256+
257+ // Covers reports whether the rule is about this tool at all, whatever the
258+ // arguments.
259+ func (r Rule) Covers(tool string) bool {
260+ for _, t := range r.Tools {
261+ if tool == t || (r.mcp && strings.HasPrefix(tool, t+"__")) {
262+ return true
263+ }
264+ }
265+ return false
266+ }
267+
268+ // Matches reports whether the rule covers the whole of this call. A shell
269+ // command matches only when every simple command in it does, and never when
270+ // the analysis cannot see what it runs. This is the reading an allow rule
271+ // wants.
272+ func (r Rule) Matches(tool string, input json.RawMessage) bool {
273+ if !r.Covers(tool) {
274+ return false
275+ }
276+ if r.WholeTool() {
277+ return true
278+ }
279+ switch r.spec {
280+ case specBash:
281+ cmds, exact := shellCommands(bashCommand(input))
282+ if !exact || len(cmds) == 0 {
283+ return false
284+ }
285+ for _, c := range cmds {
286+ if !r.bashAllows(c) {
287+ return false
288+ }
289+ }
290+ return true
291+ default:
292+ return r.matchesArg(tool, input, true)
293+ }
294+ }
295+
296+ // Touches reports whether any part of this call falls under the rule: one
297+ // simple command in a compound shell command is enough, as is anything that
298+ // can be read out of a command the analysis cannot otherwise see through. This
299+ // is the reading a deny rule wants.
300+ func (r Rule) Touches(tool string, input json.RawMessage) bool {
301+ if !r.Covers(tool) {
302+ return false
303+ }
304+ if r.WholeTool() {
305+ return true
306+ }
307+ if r.spec == specBash {
308+ for _, words := range denyCandidates(bashCommand(input), 0) {
309+ if r.bashWordsMatch(words) {
310+ return true
311+ }
312+ }
313+ return false
314+ }
315+ return r.matchesArg(tool, input, false)
316+ }
317+
318+ // matchesArg is the non-shell half of both readings. A URL is one thing; a
319+ // path is two when a symlink is involved — the name the call gave and the
320+ // file it reaches — and strict, the allow reading, wants both to match,
321+ // where the deny reading is satisfied by either.
322+ func (r Rule) matchesArg(tool string, input json.RawMessage, strict bool) bool {
323+ var args struct {
324+ Path string `json:"path"`
325+ URL string `json:"url"`
326+ }
327+ json.Unmarshal(input, &args)
328+ switch r.spec {
329+ case specPath:
330+ p := args.Path
331+ if p == "" && (tool == "glob" || tool == "grep") {
332+ // The search tools default to the working root.
333+ p = "."
334+ }
335+ return p != "" && r.pathMatchesResolved(p, strict)
336+ case specDomain:
337+ u, err := url.Parse(args.URL)
338+ if err != nil || u.Hostname() == "" {
339+ return false
340+ }
341+ host := strings.ToLower(u.Hostname())
342+ if suffix, ok := strings.CutPrefix(r.Specifier, "*."); ok {
343+ return strings.HasSuffix(host, "."+suffix)
344+ }
345+ return host == r.Specifier
346+ }
347+ return false
348+ }
349+
350+ // RuleList is a list of allow rules and a list of deny rules, decided
351+ // together. It is the RuleSet for rules given on the command line, with
352+ // --allowedTools and --disallowedTools.
353+ type RuleList struct {
354+ Allow []Rule
355+ Deny []Rule
356+ // Source is where the rules were given, for the message a refusal
357+ // carries and for /allowed. Empty reads as the command line.
358+ Source string
359+ }
360+
361+ // Empty reports whether there is nothing to decide with.
362+ func (s RuleList) Empty() bool { return len(s.Allow) == 0 && len(s.Deny) == 0 }
363+
364+ // Check decides a call. Deny wins over allow, as in Claude Code: a rule saying
365+ // no is a statement about this repository, and no allow rule written for
366+ // convenience overrides it. The rule returned is the one that decided, for a
367+ // message.
368+ //
369+ // A compound shell command is allowed when each of its simple commands is
370+ // covered by some allow rule — not necessarily the same one — so
371+ // `git status && npm test` passes under Bash(git status) and Bash(npm test:*).
372+ // The rule named then is the first that matched.
373+ func (s RuleList) Check(tool string, input json.RawMessage) Ruling {
374+ v, r := s.decide(tool, input)
375+ if v == NoVerdict {
376+ return Ruling{}
377+ }
378+ return Ruling{Verdict: v, Rule: r.Raw, Source: s.source()}
379+ }
380+
381+ // Lines implements RuleSet, for /allowed.
382+ func (s RuleList) Lines() []string {
383+ if s.Empty() {
384+ return nil
385+ }
386+ lines := []string{s.source()}
387+ for _, r := range s.Deny {
388+ lines = append(lines, " deny "+r.Raw)
389+ }
390+ for _, r := range s.Allow {
391+ lines = append(lines, " allow "+r.Raw)
392+ }
393+ return lines
394+ }
395+
396+ func (s RuleList) source() string {
397+ if s.Source == "" {
398+ return "the command line"
399+ }
400+ return s.Source
401+ }
402+
403+ func (s RuleList) decide(tool string, input json.RawMessage) (Verdict, Rule) {
404+ for _, r := range s.Deny {
405+ if r.Touches(tool, input) {
406+ return VerdictDeny, r
407+ }
408+ }
409+ if tool != "bash" {
410+ for _, r := range s.Allow {
411+ if r.Matches(tool, input) {
412+ return VerdictAllow, r
413+ }
414+ }
415+ return NoVerdict, Rule{}
416+ }
417+
418+ cmds, exact := shellCommands(bashCommand(input))
419+ if !exact || len(cmds) == 0 {
420+ // What cannot be seen through is allowed only by a rule that allows
421+ // every command there is.
422+ for _, r := range s.Allow {
423+ if r.Covers(tool) && r.WholeTool() {
424+ return VerdictAllow, r
425+ }
426+ }
427+ return NoVerdict, Rule{}
428+ }
429+ var first Rule
430+ for i, c := range cmds {
431+ covered := false
432+ for _, r := range s.Allow {
433+ if r.Covers(tool) && (r.WholeTool() || r.bashAllows(c)) {
434+ if i == 0 {
435+ first = r
436+ }
437+ covered = true
438+ break
439+ }
440+ }
441+ if !covered {
442+ return NoVerdict, Rule{}
443+ }
444+ }
445+ return VerdictAllow, first
446+ }
447+
448+ // Unsure reports whether a bash call might fall under this rule in a way the
449+ // shell analysis cannot see, which is only worth asking when Touches has said
450+ // no. A deny rule that did not match a command it could not read has not
451+ // cleared it, only failed to see it, and a caller that cares can ask instead
452+ // of letting it pass.
453+ //
454+ // That is the case when the script does not parse, hides commands in a
455+ // substitution or an inline interpreter script, or has a simple command whose
456+ // words agree with the rule's up to one the shell computes: `git "$sub"
457+ // origin` may well be `git push origin`. `ls *.go` is not unsure about
458+ // Bash(rm:*) — its name is fixed, and it is not rm.
459+ func (r Rule) Unsure(tool string, input json.RawMessage) bool {
460+ if !r.Covers(tool) || r.WholeTool() || r.spec != specBash {
461+ return false
462+ }
463+ script := bashCommand(input)
464+ f, err := syntax.NewParser().Parse(strings.NewReader(script), "")
465+ if err != nil {
466+ return true
467+ }
468+ hidden := false
469+ syntax.Walk(f, func(node syntax.Node) bool {
470+ switch n := node.(type) {
471+ case *syntax.CmdSubst, *syntax.ProcSubst:
472+ hidden = true
473+ case *syntax.CallExpr:
474+ if len(n.Args) > 0 {
475+ if name, ok := wordText(n.Args[0]); ok && interpreters[name] {
476+ var rest []string
477+ for _, w := range n.Args[1:] {
478+ t, _ := wordText(w)
479+ rest = append(rest, t)
480+ }
481+ hidden = hidden || inlineScript(rest)
482+ }
483+ }
484+ }
485+ return !hidden
486+ })
487+ if hidden {
488+ return true
489+ }
490+ want, _ := r.bashPattern()
491+ cmds, _ := shellCommands(script)
492+ for _, c := range cmds {
493+ if want == nil {
494+ // A mid-pattern wildcard is matched over the whole line, so any
495+ // computed word could be the one it needed.
496+ for _, fixed := range c.fixed {
497+ if !fixed {
498+ return true
499+ }
500+ }
501+ continue
502+ }
503+ for i, w := range want {
504+ if i >= len(c.words) {
505+ break
506+ }
507+ if !c.fixed[i] {
508+ return true
509+ }
510+ if c.words[i] != w {
511+ break
512+ }
513+ }
514+ }
515+ return false
516+ }
517+
518+ // bashCommand pulls the command out of a bash call.
519+ func bashCommand(input json.RawMessage) string {
520+ var args struct {
521+ Command string `json:"command"`
522+ }
523+ json.Unmarshal(input, &args)
524+ return args.Command
525+ }
526+
527+ // shellCmd is one simple command, as words.
528+ type shellCmd struct {
529+ words []string
530+ // fixed[i] says words[i] is exactly what the shell will pass: no
531+ // expansion, no unquoted glob. Only fixed words can match a rule's own
532+ // words; anything else can only fall under its trailing wildcard.
533+ fixed []bool
534+ }
535+
536+ // bashAllows is the strict reading for one simple command.
537+ func (r Rule) bashAllows(c shellCmd) bool {
538+ want, prefix := r.bashPattern()
539+ if want == nil {
540+ // A rule with a wildcard in the middle is matched against the command
541+ // as one string, which only means anything when every word is fixed.
542+ for _, f := range c.fixed {
543+ if !f {
544+ return false
545+ }
546+ }
547+ return wildcardMatch(r.Specifier, strings.Join(c.words, " "))
548+ }
549+ if len(c.words) < len(want) || (!prefix && len(c.words) != len(want)) {
550+ return false
551+ }
552+ for i, w := range want {
553+ if !c.fixed[i] || c.words[i] != w {
554+ return false
555+ }
556+ }
557+ return true
558+ }
559+
560+ // bashWordsMatch is the lenient reading for one candidate: the words as
561+ // written, expansions and all, against the rule.
562+ func (r Rule) bashWordsMatch(words []string) bool {
563+ want, prefix := r.bashPattern()
564+ if want == nil {
565+ return wildcardMatch(r.Specifier, strings.Join(words, " "))
566+ }
567+ if len(words) < len(want) || (!prefix && len(words) != len(want)) {
568+ return false
569+ }
570+ for i, w := range want {
571+ if words[i] != w {
572+ return false
573+ }
574+ }
575+ return true
576+ }
577+
578+ // bashPattern reads a Bash rule's argument. `git status:*` and `git status *`
579+ // are a prefix, matched word by word, so Bash(ls:*) covers `ls -la` and not
580+ // `lsof`; anything without a wildcard is an exact command. A wildcard anywhere
581+ // else returns nil, and the argument is matched as a pattern over the whole
582+ // command line.
583+ func (r Rule) bashPattern() (words []string, prefix bool) {
584+ spec := r.Specifier
585+ switch {
586+ case strings.HasSuffix(spec, ":*"):
587+ spec, prefix = strings.TrimSuffix(spec, ":*"), true
588+ case strings.HasSuffix(spec, " *"):
589+ spec, prefix = strings.TrimSuffix(spec, " *"), true
590+ }
591+ if strings.Contains(spec, "*") {
592+ return nil, false
593+ }
594+ return strings.Fields(spec), prefix
595+ }
596+
597+ // wildcardMatch matches s against a pattern in which * stands for any run of
598+ // characters, and nothing else is special.
599+ func wildcardMatch(pattern, s string) bool {
600+ parts := strings.Split(pattern, "*")
601+ if len(parts) == 1 {
602+ return pattern == s
603+ }
604+ if !strings.HasPrefix(s, parts[0]) {
605+ return false
606+ }
607+ s = s[len(parts[0]):]
608+ for _, p := range parts[1 : len(parts)-1] {
609+ i := strings.Index(s, p)
610+ if i < 0 {
611+ return false
612+ }
613+ s = s[i+len(p):]
614+ }
615+ return strings.HasSuffix(s, parts[len(parts)-1])
616+ }
617+
618+ // shellCommands lists the simple commands in a script, and reports whether
619+ // that list is the whole truth about what it runs. It is not when the script
620+ // does not parse, substitutes a command, runs an inline interpreter script,
621+ // assigns a variable — PATH=. changes what every later word means — or writes
622+ // output to a file, which no command prefix says anything about.
623+ func shellCommands(script string) ([]shellCmd, bool) {
624+ f, err := syntax.NewParser().Parse(strings.NewReader(script), "")
625+ if err != nil {
626+ return nil, false
627+ }
628+ exact := true
629+ var cmds []shellCmd
630+ syntax.Walk(f, func(node syntax.Node) bool {
631+ switch n := node.(type) {
632+ case *syntax.CmdSubst, *syntax.ProcSubst, *syntax.DeclClause, *syntax.CoprocClause:
633+ exact = false
634+ case *syntax.Stmt:
635+ for _, rd := range n.Redirs {
636+ if writesFile(rd) {
637+ exact = false
638+ }
639+ }
640+ case *syntax.CallExpr:
641+ if len(n.Assigns) > 0 {
642+ exact = false
643+ }
644+ if len(n.Args) == 0 {
645+ return true
646+ }
647+ c := shellCmd{}
648+ for _, w := range n.Args {
649+ text, fixed := wordText(w)
650+ c.words = append(c.words, text)
651+ c.fixed = append(c.fixed, fixed)
652+ }
653+ if !c.fixed[0] {
654+ // A command name the shell computes is a command nobody
655+ // can name in a rule.
656+ exact = false
657+ }
658+ if interpreters[c.words[0]] && inlineScript(c.words[1:]) {
659+ exact = false
660+ }
661+ cmds = append(cmds, c)
662+ }
663+ return true
664+ })
665+ return cmds, exact
666+ }
667+
668+ // writesFile reports whether a redirection sends output somewhere other than
669+ // another descriptor or /dev/null.
670+ func writesFile(rd *syntax.Redirect) bool {
671+ target, fixed := wordText(rd.Word)
672+ switch rd.Op {
673+ case syntax.RdrOut, syntax.AppOut, syntax.RdrAll, syntax.AppAll, syntax.ClbOut, syntax.RdrInOut:
674+ case syntax.DplOut:
675+ // >&2 duplicates a descriptor; >&name, in bash, writes a file.
676+ if fixed && (target == "-" || strings.Trim(target, "0123456789") == "") {
677+ return false
678+ }
679+ default:
680+ return false
681+ }
682+ return !fixed || target != "/dev/null"
683+ }
684+
685+ // inlineScript is hasInlineScript for words already read out.
686+ func inlineScript(args []string) bool {
687+ for _, a := range args {
688+ if a == "-c" || a == "-e" || a == "--command" {
689+ return true
690+ }
691+ }
692+ return false
693+ }
694+
695+ // wordText renders a word the way the shell will see it after quote removal,
696+ // and reports whether that rendering is certain. A word with an expansion in
697+ // it is rendered as written, which is good enough for a deny rule to look at
698+ // and never good enough for an allow rule to trust.
699+ func wordText(w *syntax.Word) (string, bool) {
700+ var b strings.Builder
701+ fixed := true
702+ for i, part := range w.Parts {
703+ switch p := part.(type) {
704+ case *syntax.Lit:
705+ if strings.ContainsAny(p.Value, "*?[") || (i == 0 && strings.HasPrefix(p.Value, "~")) ||
706+ (strings.Contains(p.Value, "{") && strings.Contains(p.Value, "}") &&
707+ (strings.Contains(p.Value, ",") || strings.Contains(p.Value, ".."))) {
708+ // A glob, a home directory or a brace expansion: the shell
709+ // passes something other than what is written.
710+ fixed = false
711+ }
712+ b.WriteString(unescape(p.Value, false))
713+ case *syntax.SglQuoted:
714+ b.WriteString(p.Value)
715+ case *syntax.DblQuoted:
716+ for _, inner := range p.Parts {
717+ if lit, ok := inner.(*syntax.Lit); ok {
718+ b.WriteString(unescape(lit.Value, true))
719+ continue
720+ }
721+ fixed = false
722+ b.WriteString(source(inner))
723+ }
724+ default:
725+ fixed = false
726+ b.WriteString(source(part))
727+ }
728+ }
729+ return b.String(), fixed
730+ }
731+
732+ // unescape removes the backslashes the shell would. Outside quotes every
733+ // backslash escapes the next character; inside double quotes only a few
734+ // characters can be escaped and the backslash stays before anything else.
735+ func unescape(s string, quoted bool) string {
736+ if !strings.Contains(s, `\`) {
737+ return s
738+ }
739+ var b strings.Builder
740+ for i := 0; i < len(s); i++ {
741+ if s[i] != '\\' || i+1 == len(s) {
742+ b.WriteByte(s[i])
743+ continue
744+ }
745+ next := s[i+1]
746+ switch {
747+ case next == '\n':
748+ i++
749+ case !quoted || strings.IndexByte("$`\"\\", next) >= 0:
750+ b.WriteByte(next)
751+ i++
752+ default:
753+ b.WriteByte(s[i])
754+ }
755+ }
756+ return b.String()
757+ }
758+
759+ // source prints a node back as shell text.
760+ func source(n syntax.Node) string {
761+ var b strings.Builder
762+ syntax.NewPrinter().Print(&b, n)
763+ return b.String()
764+ }
765+
766+ // maxShellDepth bounds how far denyCandidates follows `bash -c` into `bash -c`.
767+ const maxShellDepth = 4
768+
769+ // denyCandidates is every reading of a script a deny rule should be tried
770+ // against: each simple command, each again with a wrapper like sudo or env
771+ // taken off the front and its path stripped to the program name, and the
772+ // script inside a `bash -c` or an eval. A script that does not parse is split
773+ // on the obvious separators instead, because being unparseable is no reason to
774+ // escape a rule that would have caught it.
775+ func denyCandidates(script string, depth int) [][]string {
776+ f, err := syntax.NewParser().Parse(strings.NewReader(script), "")
777+ if err != nil {
778+ var out [][]string
779+ for _, piece := range strings.FieldsFunc(script, func(r rune) bool {
780+ return strings.ContainsRune(";&|\n()`", r)
781+ }) {
782+ if words := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(piece, `"`, ""), "'", "")); len(words) > 0 {
783+ out = append(out, unwrapped(words)...)
784+ }
785+ }
786+ return out
787+ }
788+
789+ var out [][]string
790+ syntax.Walk(f, func(node syntax.Node) bool {
791+ call, ok := node.(*syntax.CallExpr)
792+ if !ok || len(call.Args) == 0 {
793+ return true
794+ }
795+ words := make([]string, 0, len(call.Args))
796+ for _, w := range call.Args {
797+ text, _ := wordText(w)
798+ words = append(words, text)
799+ }
800+ for _, cand := range unwrapped(words) {
801+ out = append(out, cand)
802+ if depth >= maxShellDepth {
803+ continue
804+ }
805+ if inner, ok := innerScript(cand); ok {
806+ out = append(out, denyCandidates(inner, depth+1)...)
807+ }
808+ }
809+ return true
810+ })
811+ return out
812+ }
813+
814+ // innerScript is the script a command runs as a string: the argument of
815+ // `sh -c`, or everything handed to eval.
816+ func innerScript(words []string) (string, bool) {
817+ switch words[0] {
818+ case "eval":
819+ return strings.Join(words[1:], " "), len(words) > 1
820+ case "bash", "sh", "zsh", "dash", "ksh":
821+ for i, w := range words[1:] {
822+ if w == "-c" && i+2 < len(words) {
823+ return words[i+2], true
824+ }
825+ }
826+ }
827+ return "", false
828+ }
829+
830+ // wrappers run the command that follows them, so that `sudo rm -rf x` and
831+ // `timeout 5 rm -rf x` are both read as rm.
832+ var wrappers = map[string]bool{
833+ "sudo": true, "doas": true, "command": true, "builtin": true, "exec": true,
834+ "nohup": true, "nice": true, "time": true, "env": true, "timeout": true,
835+ "xargs": true, "stdbuf": true, "ionice": true,
836+ }
837+
838+ // unwrapped is a command, then the same command each time a wrapper comes off
839+ // the front, each with a path on the program name reduced to the name itself:
840+ // /bin/rm is rm as far as a rule is concerned.
841+ func unwrapped(words []string) [][]string {
842+ var out [][]string
843+ for len(words) > 0 {
844+ if strings.Contains(words[0], "/") {
845+ words = append([]string{path.Base(words[0])}, words[1:]...)
846+ }
847+ out = append(out, words)
848+ if !wrappers[words[0]] {
849+ break
850+ }
851+ wrapper := words[0]
852+ words = words[1:]
853+ // Skip the wrapper's own options and assignments. The value of an
854+ // option given as a separate word, and timeout's duration, are taken
855+ // off too; a guess that takes off one word too many only means a rule
856+ // sees a shorter command, never that it sees nothing.
857+ for len(words) > 0 {
858+ w := words[0]
859+ switch {
860+ case strings.HasPrefix(w, "-"):
861+ words = words[1:]
862+ if (wrapper == "sudo" || wrapper == "doas") && (w == "-u" || w == "-g") && len(words) > 0 {
863+ words = words[1:]
864+ }
865+ if wrapper == "nice" && w == "-n" && len(words) > 0 {
866+ words = words[1:]
867+ }
868+ continue
869+ case wrapper == "env" && strings.Contains(w, "="):
870+ words = words[1:]
871+ continue
872+ case wrapper == "timeout" && len(w) > 0 && unicode.IsDigit(rune(w[0])):
873+ words = words[1:]
874+ }
875+ break
876+ }
877+ }
878+ return out
879+ }
880+
881+ // pathMatchesResolved is pathMatches for the path as given and for the file it
882+ // resolves to. Without the second, a link named notes.txt pointing at .env
883+ // reads .env past Read(.env), and Edit(*.md) edits whatever a README.md link
884+ // points at. A file that does not exist yet — write_file's — is resolved
885+ // through its directory.
886+ func (r Rule) pathMatchesResolved(p string, strict bool) bool {
887+ given := r.pathMatches(p)
888+ abs := p
889+ if !filepath.IsAbs(abs) && r.Root != "" {
890+ abs = filepath.Join(r.Root, abs)
891+ }
892+ real, err := filepath.EvalSymlinks(abs)
893+ if err != nil {
894+ dir, derr := filepath.EvalSymlinks(filepath.Dir(abs))
895+ if derr != nil {
896+ return given
897+ }
898+ real = filepath.Join(dir, filepath.Base(abs))
899+ }
900+ if real == filepath.Clean(abs) {
901+ return given
902+ }
903+ // The resolved path is compared against the resolved root, or a root
904+ // that is itself reached through a link would match nothing.
905+ rr := r
906+ if r.Root != "" {
907+ if root, err := filepath.EvalSymlinks(r.Root); err == nil {
908+ rr.Root = root
909+ }
910+ }
911+ viaLink := rr.pathMatches(real)
912+ if strict {
913+ return given && viaLink
914+ }
915+ return given || viaLink
916+ }
917+
918+ // pathMatches reads a path pattern the way Claude Code does, which is the way
919+ // gitignore does: // starts an absolute path, ~/ the home directory, / and ./
920+ // the root, and a pattern with no slash in it matches a name at any depth. **
921+ // crosses directories and * does not. A pattern that matches a directory
922+ // matches everything under it, as a gitignore line does: Read(secrets) is
923+ // about the files in secrets/, not only the directory entry.
924+ func (r Rule) pathMatches(p string) bool {
925+ pattern := r.Specifier
926+ // gitignore anchors a pattern with a slash anywhere but at its end; one
927+ // without is a name, matched at any depth.
928+ anchored := strings.Contains(strings.TrimSuffix(pattern, "/"), "/")
929+ if strings.HasSuffix(pattern, "/") {
930+ pattern += "**"
931+ }
932+
933+ target := filepath.ToSlash(filepath.Clean(p))
934+ if !filepath.IsAbs(p) && r.Root != "" {
935+ target = filepath.ToSlash(filepath.Join(r.Root, p))
936+ }
937+ under := func(rel string) string {
938+ if r.Root == "" {
939+ return rel
940+ }
941+ return filepath.ToSlash(filepath.Clean(r.Root)) + "/" + rel
942+ }
943+
944+ var full string
945+ switch {
946+ case strings.HasPrefix(pattern, "//"):
947+ full = pattern[1:]
948+ case strings.HasPrefix(pattern, "~/"):
949+ home := r.Home
950+ if home == "" {
951+ home, _ = os.UserHomeDir()
952+ }
953+ full = filepath.ToSlash(filepath.Clean(home)) + "/" + pattern[2:]
954+ case strings.HasPrefix(pattern, "./"):
955+ full = under(pattern[2:])
956+ case strings.HasPrefix(pattern, "/"):
957+ full = under(pattern[1:])
958+ case !anchored:
959+ full = under("**/" + pattern)
960+ default:
961+ full = under(pattern)
962+ }
963+ pat, segs := segments(full), segments(target)
964+ for i := len(segs); i > 0; i-- {
965+ if globMatch(pat, segs[:i]) {
966+ return true
967+ }
968+ }
969+ return false
970+ }
971+
972+ // segments splits a slash path into its parts, dropping the empty and "."
973+ // ones a join or a leading slash leaves behind.
974+ func segments(p string) []string {
975+ var out []string
976+ for _, s := range strings.Split(p, "/") {
977+ if s != "" && s != "." {
978+ out = append(out, s)
979+ }
980+ }
981+ return out
982+ }
983+
984+ // globMatch matches path segments against pattern segments, with ** standing
985+ // for any number of segments, none included.
986+ func globMatch(pat, segs []string) bool {
987+ for len(pat) > 0 {
988+ if pat[0] == "**" {
989+ for i := 0; i <= len(segs); i++ {
990+ if globMatch(pat[1:], segs[i:]) {
991+ return true
992+ }
993+ }
994+ return false
995+ }
996+ if len(segs) == 0 {
997+ return false
998+ }
999+ if ok, err := path.Match(pat[0], segs[0]); err != nil || !ok {
1000+ return false
1001+ }
1002+ pat, segs = pat[1:], segs[1:]
1003+ }
1004+ return len(segs) == 0
1005+ }
+514−0internal/approval/rules_test.goaddedView File

Large diffs are not rendered by default.

+56−1internal/approval/ruleset.goView File

Large diffs are not rendered by default.

+0−43internal/approval/ruleset_test.goView File

Large diffs are not rendered by default.

+0−158internal/approval/simple.godeleted

Large diffs are not rendered by default.

+20−319internal/ccsettings/match.goView File

Large diffs are not rendered by default.

+4−3internal/ccsettings/ruleset_test.goView File

Large diffs are not rendered by default.

+28−0internal/tools/registry.goView File

Large diffs are not rendered by default.

+23−0internal/tools/registry_test.goView File

Large diffs are not rendered by default.