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>
Showing 18 changed files with 3247 additions and 535 deletions.
| @@ -120,7 +120,13 @@ Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`, | ||
| 120 | 120 | `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`, |
| 121 | 121 | `-no-tui`, `-no-skills`, `-no-agents`, `-no-web`, `-no-hooks`, |
| 122 | 122 | `-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. | |
| 124 | 130 | |
| 125 | 131 | ### Flags and thresholds |
| 126 | 132 | |
| @@ -1617,6 +1623,135 @@ the only one where pressing enter through the screens does something: the row | ||
| 1617 | 1623 | names the exact path before you answer, and the notice afterwards names the |
| 1618 | 1624 | file that was written. Uninstalling is `rm -r ~/.agents/skills/herdr`. |
| 1619 | 1625 | |
| 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 | + | |
| 1620 | 1755 | ## Inside another editor |
| 1621 | 1756 | |
| 1622 | 1757 | Codey can be the agent behind somebody else's UI. It speaks the |
| @@ -1710,7 +1845,11 @@ a lot. | ||
| 1710 | 1845 | |
| 1711 | 1846 | **Approval prompts.** Read-only tools run unattended. `bash`, `edit_file`, |
| 1712 | 1847 | `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. | |
| 1714 | 1853 | |
| 1715 | 1854 | Shell commands are parsed with `mvdan.cc/sh/syntax` so the prompt can tell you |
| 1716 | 1855 | 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 | ||
| 1778 | 1917 | internal/websearch the search command, the URL jail, HTML to text |
| 1779 | 1918 | internal/mcp MCP client: starts the configured servers, offers their tools |
| 1780 | 1919 | 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 | |
| 1782 | 1921 | internal/ccsettings Claude Code's MCP servers, permission rules and CLAUDE.md, and who may grant what |
| 1783 | 1922 | internal/ask questions the agent puts to you mid-task, and the tool for it |
| 1784 | 1923 | internal/hooks the lifecycle, published: fan-out, and the config's commands |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -63,6 +63,7 @@ const hostedSearchesPerTurn = 5 | ||
| 63 | 63 | |
| 64 | 64 | func main() { |
| 65 | 65 | if err := run(); err != nil { |
| 66 | + reportFailure(err) | |
| 66 | 67 | fmt.Fprintln(os.Stderr, "codey:", err) |
| 67 | 68 | os.Exit(1) |
| 68 | 69 | } |
| @@ -97,6 +98,8 @@ func run() error { | ||
| 97 | 98 | noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it") |
| 98 | 99 | noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered") |
| 99 | 100 | 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) | |
| 100 | 103 | |
| 101 | 104 | // Read the line before the flag package does. Three invocations belong to |
| 102 | 105 | // a program driving Codey rather than to a person — see agentcmd.go — and |
| @@ -106,7 +109,7 @@ func run() error { | ||
| 106 | 109 | printVersion(os.Stdout) |
| 107 | 110 | return nil |
| 108 | 111 | } |
| 109 | - if err := flag.CommandLine.Parse(inv.Rest); err != nil { | |
| 112 | + if err := flag.CommandLine.Parse(prepareArgs(flag.CommandLine, inv.Rest, claudeFlags)); err != nil { | |
| 110 | 113 | return err |
| 111 | 114 | } |
| 112 | 115 | |
| @@ -115,6 +118,19 @@ func run() error { | ||
| 115 | 118 | return err |
| 116 | 119 | } |
| 117 | 120 | |
| 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 | + | |
| 118 | 134 | // A clone last used under the old name keeps its sessions and its event |
| 119 | 135 | // log. This runs before anything opens a file inside the state directory, |
| 120 | 136 | // and is reported rather than fatal: a stale directory that cannot be |
| @@ -237,6 +253,9 @@ func run() error { | ||
| 237 | 253 | fmt.Fprintln(os.Stderr, "codey: "+msg) |
| 238 | 254 | } |
| 239 | 255 | |
| 256 | + for _, w := range cli.Warnings { | |
| 257 | + notice("%s", w) | |
| 258 | + } | |
| 240 | 259 | if cfgErr != nil { |
| 241 | 260 | notice("%v — using defaults; /setup will write a fresh one", cfgErr) |
| 242 | 261 | } |
| @@ -348,6 +367,11 @@ func run() error { | ||
| 348 | 367 | } |
| 349 | 368 | } |
| 350 | 369 | } |
| 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 | + } | |
| 351 | 375 | |
| 352 | 376 | // Whatever this machine can say about a file the agent has just written. |
| 353 | 377 | // A nil Runner is the off switch, and every call site takes one, so the |
| @@ -432,9 +456,13 @@ func run() error { | ||
| 432 | 456 | // /worktree rebuilds the tool set against a different directory: each tool |
| 433 | 457 | // closes over the root it is handed, so moving the work means building the |
| 434 | 458 | // 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. | |
| 435 | 463 | toolsIn := func(root string) func(*agent.State, *metrics.Logger) *tools.Registry { |
| 436 | 464 | 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( | |
| 438 | 466 | tools.NewRead(root, state, skillDirs...), |
| 439 | 467 | tools.NewGlob(root, skillDirs...), |
| 440 | 468 | tools.NewGrep(root, skillDirs...), |
| @@ -473,15 +501,21 @@ func run() error { | ||
| 473 | 501 | in.Buffer(make([]byte, 0, 64*1024), 1024*1024) |
| 474 | 502 | |
| 475 | 503 | 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()} | |
| 477 | 505 | 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 | + } | |
| 479 | 513 | |
| 480 | 514 | // Questions go to the line front end until a TUI takes them over in |
| 481 | 515 | // tui.Run. The tool belongs to the session's own agent and is registered |
| 482 | 516 | // here rather than in newTools, so a subagent still cannot stop and ask: |
| 483 | 517 | // 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) | |
| 485 | 519 | reg.Register(ask.Tool(asker.Ask)) |
| 486 | 520 | // The plan belongs to the session agent for the same reason the question |
| 487 | 521 | // does: a subagent keeping its own list would show the user a plan for |
| @@ -577,6 +611,11 @@ func run() error { | ||
| 577 | 611 | base.Models = models |
| 578 | 612 | } |
| 579 | 613 | |
| 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 | + | |
| 580 | 619 | a := agent.New(client, reg, state, log, base) |
| 581 | 620 | a.Observe = render.Handle |
| 582 | 621 | a.Approve = policy |
| @@ -713,7 +752,7 @@ func run() error { | ||
| 713 | 752 | // to come up in the tab whose agent is asking. |
| 714 | 753 | pol := approval.New(nil) |
| 715 | 754 | pol.YOLO = acceptAll |
| 716 | - pol.Rules = cc.Rules | |
| 755 | + pol.Rules = approval.Combine(cc.Rules, cli.Rules) | |
| 717 | 756 | |
| 718 | 757 | var tabSup *subagent.Supervisor |
| 719 | 758 | if !*noAgents { |
| @@ -857,6 +896,12 @@ func run() error { | ||
| 857 | 896 | ctx, stopSignals := interruptible(context.Background()) |
| 858 | 897 | defer stopSignals() |
| 859 | 898 | |
| 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 | + } | |
| 860 | 905 | if *prompt != "" { |
| 861 | 906 | return once(ctx, a, render, sessionDir, session, root, client, *prompt) |
| 862 | 907 | } |
| @@ -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 | + } | |
| @@ -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, ¬es, 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, ¬es, &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 | + } | |
| @@ -14,6 +14,7 @@ require ( | ||
| 14 | 14 | github.com/invopop/jsonschema v0.14.0 |
| 15 | 15 | github.com/muesli/termenv v0.16.0 |
| 16 | 16 | golang.org/x/net v0.38.0 |
| 17 | + golang.org/x/term v0.45.0 | |
| 17 | 18 | gopkg.in/yaml.v3 v3.0.1 |
| 18 | 19 | mvdan.cc/sh/v3 v3.14.1 |
| 19 | 20 | ) |
| @@ -48,6 +49,5 @@ require ( | ||
| 48 | 49 | github.com/yuin/goldmark-emoji v1.0.6 // indirect |
| 49 | 50 | go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect |
| 50 | 51 | golang.org/x/sys v0.47.0 // indirect |
| 51 | - golang.org/x/term v0.45.0 // indirect | |
| 52 | 52 | golang.org/x/text v0.30.0 // indirect |
| 53 | 53 | ) |
| @@ -304,6 +304,11 @@ type Options struct { | ||
| 304 | 304 | // the model is done, the window is full, or the context is cancelled. |
| 305 | 305 | MaxTurns int |
| 306 | 306 | 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 | |
| 307 | 312 | // Effort maps to output_config.effort. Empty means the API default (high). |
| 308 | 313 | Effort string |
| 309 | 314 | // ContextWindow, CompactAt and CompactFloor control when the conversation |
| @@ -392,6 +397,14 @@ const ( | ||
| 392 | 397 | // EventResumed when it is let go again. |
| 393 | 398 | EventPaused EventKind = "paused" |
| 394 | 399 | 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" | |
| 395 | 408 | ) |
| 396 | 409 | |
| 397 | 410 | // Event is what the loop reports to an observer. It carries plain data only — |
| @@ -411,6 +424,11 @@ type Event struct { | ||
| 411 | 424 | Result string |
| 412 | 425 | IsError bool |
| 413 | 426 | 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 | |
| 414 | 432 | } |
| 415 | 433 | |
| 416 | 434 | // 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 | ||
| 563 | 581 | opts.System += opts.Instructions |
| 564 | 582 | opts.System += skillListing(opts.Skills) |
| 565 | 583 | opts.System += modelListing(opts.Models) |
| 584 | + if opts.SystemAppend != "" { | |
| 585 | + opts.System += "\n\n" + opts.SystemAppend | |
| 586 | + } | |
| 566 | 587 | return &Agent{client: client, tools: reg, state: state, log: log, opts: opts} |
| 567 | 588 | } |
| 568 | 589 | |
| @@ -694,6 +715,7 @@ func (a *Agent) drive(ctx context.Context) (string, error) { | ||
| 694 | 715 | // The assistant message goes into history verbatim, thinking blocks and |
| 695 | 716 | // all: their signatures must come back unchanged on the next request. |
| 696 | 717 | a.state.Append(resp.Message()) |
| 718 | + a.emit(Event{Kind: EventMessage, Turn: turn, Response: resp}) | |
| 697 | 719 | |
| 698 | 720 | // Hosted tool calls are shown here, once the response is complete, |
| 699 | 721 | // 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) { | ||
| 735 | 757 | // message. Splitting them across messages is an API error, and even |
| 736 | 758 | // where it is not it teaches the model to stop calling tools in |
| 737 | 759 | // 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}) | |
| 739 | 763 | |
| 740 | 764 | case llm.StopPauseTurn: |
| 741 | 765 | // A server-side tool needs another round trip; resend as-is. Hosted |
| @@ -1146,3 +1146,50 @@ func TestRedactedReasoningIsNotReported(t *testing.T) { | ||
| 1146 | 1146 | t.Error("the redacted block was dropped from the conversation") |
| 1147 | 1147 | } |
| 1148 | 1148 | } |
| 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 | + } | |
| @@ -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 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.