From 21a1f7c826dba231760effbfce32c3d181a7b572 Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Tue, 3 Nov 2026 21:28:30 +0100 Subject: [PATCH] feat(guard): read curl's, wget's and gh api's options The guard now watches curl, wget and gh api only when they send data: by a method other than GET and HEAD, or, with no method, with data to send. It follows each tool's way of reading its options (fact 35) and fails closed on what only the run decides (decision 51). --- docs/plan.md | 9 +- internal/guard/guard.go | 3 + internal/guard/guard_test.go | 2 - internal/guard/http.go | 220 +++++++++++++++++++++++++++++++++++ internal/guard/http_test.go | 141 ++++++++++++++++++++++ internal/guard/outward.go | 7 +- 6 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 internal/guard/http.go create mode 100644 internal/guard/http_test.go diff --git a/docs/plan.md b/docs/plan.md index 43e3969..aab8fd7 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -52,8 +52,8 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `config` | `internal/config`: baton's config file, with each role's model, reasoning effort and AI credits cap, the orchestrator's extra tools, the round cap and the branch prefix, for every repo and per repo, by the remote's URL, over baton's own; the whole file checked when read (decisions 16 and 49, fact 31) | Done (#29) | | `config-cli` | `baton pane` and `baton feature start` take their settings from the config, for the repo of the `origin` remote's URL: each role's model, reasoning effort and AI credits cap, and the orchestrator's extra tools, in place of the pane's model and nudge's tools, and the round cap and the branch prefix in place of the start's (decision 49, fact 32); the README's config section | Done (#30) | | `bash` | `internal/bash`: the commands that a bash script runs, each as the words that bash passes it, read with mvdan.cc/sh's parser: those in substitutions, here-documents and functions too, the command that a wrapper such as `env`, `nohup` or `xargs` runs, and the commands of the scripts of `sh -c` and `eval` (decision 50, fact 33) | Done (#31) | -| `guard` | `internal/guard`: the commands of a bash call that the guard watches, by program: git's commands that send to another host, such as `git push`, `gh`'s commands but those that only read, `ssh`, `scp`, `sftp` and Jira's CLIs; `tmux` and `osascript`; another agent's CLI; `baton`, with its command; and a program that only the run decides (decision 51, fact 34) | In review (#32) | -| `guard-http` | `internal/guard` reads the options of `curl`, `wget` and `gh api`, and watches only those that send data (decision 51) | Todo | +| `guard` | `internal/guard`: the commands of a bash call that the guard watches, by program: git's commands that send to another host, such as `git push`, `gh`'s commands but those that only read, `ssh`, `scp`, `sftp` and Jira's CLIs; `tmux` and `osascript`; another agent's CLI; `baton`, with its command; and a program that only the run decides (decision 51, fact 34) | Done (#32) | +| `guard-http` | `internal/guard` reads the options of `curl`, `wget` and `gh api`, and watches only those that send data (decision 51, fact 35) | In review (#33) | | `guard-hook` | `baton hook guard` with its `preToolUse` hook in the plugin: in a role's pane, it denies the commands that the guard watches, but baton's commands that a role runs, a script that doesn't parse, and the file tools' writes in baton's folder (decisions 9, 17 and 48; risk 11) | Todo | | `guard-orchestrator` | In the orchestrator's pane, `baton hook guard` asks the user before outward actions, and before `baton feature start` while plan approval is on, and denies `edit` and `create` outside its session's folder; the orchestrator then runs with `--allow-all` (decisions 8, 19 and 45) | Todo | | `belt` | Risk 2's second belt, if it works: the role panes' credentials taken away, such as an empty `GH_CONFIG_DIR` and a git `pushInsteadOf` that fails | Todo | @@ -129,6 +129,7 @@ Checked on 2026-10-31 with Copilot CLI 1.0.105 and tmux 3.8, unless a fact says 32. **The `origin` remote's URL** (git 2.56.0, on 2026-11-03). `git remote get-url origin` prints the remote's first URL, with the user's `url..insteadOf` rewrites applied, from any of the repo's worktrees. Without an `origin` remote, it prints `error: No such remote 'origin'` and exits with 2, as git-remote's manual says since git 2.30; outside a repo, it exits with 128. A remote that has a section in git's config but no URL prints its own name. 33. **mvdan.cc/sh reads the agents' bash** (v3.14.1, on 2026-11-03). Of the 32,473 distinct commands of the bash tool calls in the user's Copilot CLI sessions, its bash parser read all but one into 181,550 commands, in 0.2 seconds; the one that failed holds `$[…]`, bash's old arithmetic, on which bash fails too when it runs it. The parser drops a NUL byte without an error, and keeps a backslash that ends a word. 34. **git's options and gh's commands** (git 2.56.0 and gh 2.103.2, on 2026-11-03). Before its command, git takes the next word as the value of `-C`, `-c`, `--attr-source`, `--config-env`, `--git-dir`, `--namespace`, `--shallow-file` and `--work-tree`, unless the option holds it after `=`, and fails on an option that it doesn't know, `--super-prefix` among them, which older gits take with a value. `--` ends git's options. git's and gh's commands match case: `git PUSH` fails with `cannot handle PUSH as a builtin`, and `gh PR view` with `unknown command "PR" for "gh"`. +35. **curl's, wget's and gh api's options** (curl 8.7.1, GNU Wget 1.25.0 and gh 2.103.2, on 2026-11-03, from their help and their requests to a server on 127.0.0.1). curl sends data with `-d` (`--data`, and its `-ascii`, `-binary`, `-raw` and `-urlencode` forms), `-F` (`--form`, `--form-string`), `--json` and `-T` (`--upload-file`), which, with no method (`-X`, `--request`), make a POST, or a PUT for `-T`. With `-X GET`, `-d` sends a GET with a body. `-G` puts the data of `-d` and `--json` into the URL, but leaves `-F` a POST and `-T` a PUT, and `--no-get` undoes it; `-I` with `-d` fails. `--expand-` before an option's name fills in variables (`--expand-data` POSTs), and `--next` (`-:`) starts another request with options of its own. curl refuses a long option's abbreviation; wget takes the start of a name (`--meth=POST`, `--post-d=x`). wget sends data with `--post-data` and `--post-file`, and, with `--method`, `--body-data` and `--body-file`; `-e` (`--execute`) gives it a line of its settings, whose name ignores case, `-` and `_` (`-e Post_Data=x` POSTs). gh api POSTs once it has a field (`-f`, `-F`, `--raw-field`, `--field`) or `--input`, unless `-X GET` (`--method`) puts the fields into the URL, and it reads `-X=GET` as `GET`. A short option that takes a value, by each tool's help, takes the rest of its word, or else the next word: curl's `AbcCdDeEFhHKmoPQrtTuUwxXyYz`, wget's `aABDeiIlnoOPQRtTUwX` (`-nv` is `-n` with `v`) and gh api's `fFHpqtX`. curl also reads its options from `~/.curlrc` and from `-K` (`--config`) files, and wget its settings from `~/.wgetrc` and the file that `WGETRC` names. ## 3. Design @@ -186,7 +187,7 @@ It keeps copilot's own prompts, since the user watches that pane, `ask_user`, si **Questions** (decisions 18 and 46). A role never asks the user. The holder starts `baton ask ""` in a background shell and ends its turn, as with `baton wait`: baton stores the question in the feature's file and logs it, then reads the file every second until the answer comes, prints it and exits, and its end wakes the role (fact 5). The orchestrator's `baton wait` picks up the question, as it picks up its work, and prints it with the command that answers it. The orchestrator answers from the plan with `baton answer ""`, or asks the user first, in its pane or through nudge. A role asks one question at a time, and can't hand over while it waits for the answer. Asking the same question again doesn't ask it twice: it waits for the answer, or prints it if it came, so a role whose `baton ask` ended early still gets its answer. The role's handover clears its question, which the journal keeps. -**Safety** (decisions 9, 17, 19 and 39). The roles run with every permission (`--allow-all`), in a worktree that their `copilot` trusts, and no `ask_user`, so nothing waits on a prompt in a pane nobody watches, and with the tool allow-list, so no hidden agent and none of the user's skills or MCP tools. The guard hook (`preToolUse` → `baton hook guard`) denies outward actions in a role's pane: `git push`, pull request and merge commands, `gh api` writes, HTTP writes with `curl`, Jira writes, `ssh`, `baton deliver`, `tmux`, another agent's CLI, and a program that only the run decides (decision 51). In the orchestrator's pane, it answers `ask` for outward actions, so the user approves each one, and for `baton feature start` while plan approval is on (decision 8); and it denies `edit` and `create` outside its session's folder, where it writes its briefs and notes (decision 48), so the orchestrator plans and dispatches but doesn't write the code. The guard reads the commands of a bash call with a bash parser (decision 50), not its text, and quickly, because a hook that times out allows (fact 4). Until the guard comes, the orchestrator runs without `--allow-all`, so `copilot` asks the user before each of its tools acts (decision 45). +**Safety** (decisions 9, 17, 19 and 39). The roles run with every permission (`--allow-all`), in a worktree that their `copilot` trusts, and no `ask_user`, so nothing waits on a prompt in a pane nobody watches, and with the tool allow-list, so no hidden agent and none of the user's skills or MCP tools. The guard hook (`preToolUse` → `baton hook guard`) denies outward actions in a role's pane: `git push`, pull request and merge commands, `gh api` writes, HTTP writes with `curl` and `wget`, Jira writes, `ssh`, `baton deliver`, `tmux`, another agent's CLI, and a program that only the run decides (decision 51). In the orchestrator's pane, it answers `ask` for outward actions, so the user approves each one, and for `baton feature start` while plan approval is on (decision 8); and it denies `edit` and `create` outside its session's folder, where it writes its briefs and notes (decision 48), so the orchestrator plans and dispatches but doesn't write the code. The guard reads the commands of a bash call with a bash parser (decision 50), not its text, and quickly, because a hook that times out allows (fact 4). Until the guard comes, the orchestrator runs without `--allow-all`, so `copilot` asks the user before each of its tools acts (decision 45). **State** (decision 15). Everything lives in `$(git rev-parse --git-common-dir)/baton/`, which every worktree of the repo shares and git never commits: @@ -267,7 +268,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 48. **Only baton writes in its folder** (lead's call, flagged; fact 29, risk 11). `baton handover` refuses a note, and `baton feature start` a brief, that lies in baton's folder, once a relative path is made absolute from the working folder, so that the check and the read look at the same file, and the symlinks of both paths are resolved; it says to move the file to the session's `files/` folder, and the refusal changes nothing. In the live run the reviewer wrote its notes there, named as baton names the next one: a stray file there misleads whoever reads the notes, and baton numbers a new note after the folder's highest one (decision 26), so a stray shifts the numbers. The prompts send each role's notes, and the orchestrator's briefs and notes, to its session's `files/` folder (`$COPILOT_HOME/session-state//files/`), which Copilot CLI names in each session's context, never to the repo's worktrees, where they could get committed, or to baton's folder. They also tell the roles that the folder of the notes that baton hands them is baton's, and to run git and their other commands in the feature's worktree, where their pane starts, since the notes' paths run through the main worktree. So decision 19's guard lets the orchestrator `edit` and `create` only in its session's folder, no longer in baton's. On a disk that ignores case, as a Mac's does by default, a path that spells baton's folder in other case gets through, and the refusal stops only the note that a handover names: the guard's `preToolUse` hook can keep the file tools out of baton's folder (risk 11). baton doesn't refuse a note in a worktree, which only risks a commit, and doesn't remove a stray that is there already. 49. **One config file, in layers** (lead's call, flagged; decision 16, fact 31). `config.json` in baton's folder under the user's config folder holds the settings for every repo at its top, and each repo's section in `repos`, keyed by the remote's URL as its host and path: `git@github.com:codedogapp/baton.git`, `https://github.com/codedogapp/baton` and `ssh://git@github.com:22/codedogapp/baton.git` all give `github.com/codedogapp/baton`, so a repo's HTTPS and SSH clones share a section; a remote that is a local path keeps its path. baton refuses a key in another form, and says the form; a key keeps the URL's case. The repo's section lies over the top, and the top over baton's own settings, one setting at a time: a setting that a layer leaves out, or gives its zero value, keeps the one below, so a `roundCap` or a `maxAiCredits` of 0 keeps the layer's below, and the branch prefix can't be empty; the orchestrator's `extraTools`, a list, replaces the list below whole, and `[]` gives none. baton's own: Claude Opus 5.5 for every role (decision 7), copilot's own reasoning effort, no cap, nudge's two tools for the orchestrator (decision 45), three rounds (decision 13) and `feature/` (decision 41). baton reads and checks the whole file, the other repos' sections too, so that a mistake shows at once, with the file's path, and not in a `copilot` that it stops: it refuses a member that it doesn't know (names match case: `roundCap`, not `RoundCap`), a member given twice, a role that it doesn't know, a model or a tool that starts with a dash, which would read as one of copilot's flags, a reasoning effort that copilot doesn't take (CLI 1.0.105's, so a new level needs a new baton), a cap below copilot's least, 30 (fact 10), a negative round cap, and extra tools for any role but the orchestrator (decision 10). A missing file gives baton's own settings. The step came in two: `config` reads the file, and `config-cli` gives its settings to `baton pane` and `baton feature start`. They find the repo's section by its `origin` remote's URL, as `git remote get-url origin` gives it, with the user's `insteadOf` rewrites (fact 32): a repo can have many remotes, and `origin` is the one that `git clone` makes. A repo without an `origin` gets the top's settings, even with other remotes, since baton can't tell which of them is the repo's own. `baton pane` reads the file after it writes the plugin and before it waits (decision 38), the orchestrator's pane too, so a mistake in the file stops a pane before its role picks up work; `baton feature start` reads it before it makes the feature; and `baton start` reads it before it makes the orchestrator's session, since a pane that fails takes its session, and its reason, with it, but not to show a session that runs, which has its settings already. A role's settings come after its plugin on copilot's command line: `--model`, then `--reasoning-effort` and `--max-ai-credits` only when the file sets them, the cap in plain digits, as `1000000.5`, never as `1e+06`. The other settings come with their steps. 50. **The guard reads bash with mvdan.cc/sh** (the user's call, 2026-11-03, for the module; lead's call, flagged, for the rest). The guard needs the commands that a bash call runs: a match on the text would miss `git -C x push`, `nohup git push` and `$(git push)`, and catch `echo 'git push'`. The standard library has no bash parser, so `internal/bash`, and only it, imports `mvdan.cc/sh/v3`, the parser of `shfmt`, pinned at exactly v3.14.1 in `go.mod`, which Renovate updates as it does the rest; its license is BSD-3, whose notice the release carries (`homebrew`). `bash.Calls` gives each command that a script runs as its words, as bash passes them: quotes and backslashes taken off, and each part that only the run decides, such as `$HOME` or `$(…)`, as a marker, `bash.Dynamic`. It finds the commands in substitutions, here-documents, functions and conditions too; for a wrapper (`builtin`, `caffeinate`, `command`, `env`, `exec`, `nice`, `nohup`, `stdbuf`, `sudo`, `time`, `timeout`, `xargs` and Homebrew's `g` names, such as `gtimeout`), the command that it runs, past its options, `NAME=value` words and `timeout`'s duration; and for `eval`, and `bash`, `dash`, `ksh`, `sh` and `zsh` with `-c`, the commands of their script. A program's name ignores case, as a Mac's file system does. A script that doesn't parse is an error, which the guard denies, and that is rare (fact 33). It can't see what only the run decides: an alias, a script file or `source`, a variable as the program (`"$X" push`), a script on standard input (`sh -s`, `| sh`, `bash < 0 { + args = req.option(options, args) + } + + return req.writes() +} + +// option reads the option in the first of the words, if it is one, and returns the words that +// follow it and any value that it takes. +func (r *request) option(options client, args []string) []string { + word, rest := args[0], args[1:] + + if strings.HasPrefix(word, "--") { + name, value, inline := strings.Cut(word[2:], "=") + + opt := options.lookup(name) + if !inline && opt.valued() { + return r.take(opt, rest) + } + + r.set(opt, value) + + return rest + } + + if !strings.HasPrefix(word, "-") { + return rest + } + + for letters := word[1:]; letters != ""; letters = letters[1:] { + opt := options.letter(letters[0]) + + switch { + case !opt.valued() && !strings.Contains(options.valued, letters[:1]): + r.set(opt, "") + case len(letters) > 1: + r.set(opt, letters[1:]) + + return rest + default: + return r.take(opt, rest) + } + } + + return rest +} + +// letter returns the part of a short option's letter. +func (c client) letter(b byte) part { + if b == bash.Dynamic[0] { + return unseen + } + + return c.short[b] +} + +// lookup returns the part of a long option, by its name or by the start of a name, as wget +// allows: the first name in order that starts so, which is the name itself if there is one. curl's +// --expand- names, which fill in variables, mean the options without the prefix. +func (c client) lookup(name string) part { + name = strings.TrimPrefix(name, "expand-") + + switch { + case strings.Contains(name, bash.Dynamic): + return unseen + case name == "": + return 0 + } + + for _, long := range slices.Sorted(maps.Keys(c.long)) { + if strings.HasPrefix(long, name) { + return c.long[long] + } + } + + return 0 +} + +// take sets the part to the first of the words, or to an empty value if there is none, and returns +// the words after it. +func (r *request) take(p part, args []string) []string { + if len(args) == 0 { + args = []string{""} + } + + r.set(p, args[0]) + + return args[1:] +} + +// set records an option's part, with its value if it takes one. +func (r *request) set(p part, value string) { + switch p { + case method: + r.methods = append(r.methods, strings.ToUpper(value)) + case body: + r.body = true + case setting: + r.set(config(value)) + case next: + *r = request{wrote: r.writes()} + case unseen: + r.wrote = true + } +} + +// writes reports whether the requests send data: by a method other than GET or HEAD, or, with no +// method, by any data to send. +func (r *request) writes() bool { + return r.wrote || slices.ContainsFunc(r.methods, writing) || (r.methods == nil && r.body) +} + +// valued reports whether an option of the part takes a value. +func (p part) valued() bool { + return p == method || p == body || p == setting +} + +// writing reports whether a request's method sends data, as any but GET and HEAD may. +func writing(method string) bool { + return method != "GET" && method != "HEAD" +} + +// config returns the part that a line of wget's settings sets, such as method=POST, and its value. +// wget drops the case, - and _ of a setting's name. A name that only the run decides could be any. +func config(line string) (part, string) { + name, value, _ := strings.Cut(line, "=") + name = strings.NewReplacer("-", "", "_", "").Replace(strings.ToLower(strings.TrimSpace(name))) + + switch name { + case "method": + return method, strings.TrimSpace(value) + case "bodydata", "bodyfile", "postdata", "postfile": + return body, "" + } + + if strings.Contains(name, bash.Dynamic) { + return unseen, "" + } + + return 0, "" +} diff --git a/internal/guard/http_test.go b/internal/guard/http_test.go new file mode 100644 index 0000000..ba947dc --- /dev/null +++ b/internal/guard/http_test.go @@ -0,0 +1,141 @@ +package guard_test + +import ( + "fmt" + "testing" +) + +func TestCommandsFindsCurlsWrites(t *testing.T) { + wantCommands(t, map[string][]string{ + "curl -d x https://h": {"outward: curl"}, + "curl --data=x https://h": {"outward: curl"}, + "curl --data-ascii x https://h": {"outward: curl"}, + "curl --data-binary @f https://h": {"outward: curl"}, + "curl --data-raw x https://h": {"outward: curl"}, + "curl --data-urlencode q=x https://h": {"outward: curl"}, + "curl -F f=@x https://h": {"outward: curl"}, + "curl --form f=@x https://h": {"outward: curl"}, + "curl --form-string f=x https://h": {"outward: curl"}, + "curl --json '{}' https://h": {"outward: curl"}, + "curl -T x sftp://h/": {"outward: curl"}, + "curl --upload-file x https://h": {"outward: curl"}, + "curl --expand-data '{{x}}' https://h": {"outward: curl"}, + "curl -X DELETE https://h/x": {"outward: curl"}, + "curl --request patch https://h": {"outward: curl"}, + "curl --request=PUT https://h": {"outward: curl"}, + "curl -sSXPOST https://h": {"outward: curl"}, + "curl -X GET -X POST https://h": {"outward: curl"}, + "curl -G -d q=x https://h": {"outward: curl"}, + "curl -d x https://a --next https://b": {"outward: curl"}, + "curl -X GET https://a --next -d x https://b": {"outward: curl"}, + "curl -X GET https://a -: -d x https://b": {"outward: curl"}, + "curl https://h -X": {"outward: curl"}, + `curl -X "$method" https://h`: {"outward: curl"}, + `curl -$letter POST https://h`: {"outward: curl"}, + `curl --"$name"=x https://h`: {"outward: curl"}, + "CURL -d x https://h": {"outward: curl"}, + }) +} + +func TestCommandsLetsCurlsReadsBe(t *testing.T) { + wantCommands(t, map[string][]string{ + "curl": nil, + "curl -fsSL -o out https://h/x": nil, + "curl -I https://h": nil, + "curl -X HEAD https://h": nil, + "curl -X get https://h": nil, + "curl --request=GET https://h": nil, + "curl -X GET -d x https://h": nil, + "curl -X GET -d -XPOST https://h": nil, + "curl -H'X-Debug: 1' https://h": nil, + "curl -X GET https://a --next https://b": nil, + "curl --no-progress-meter https://h": nil, + "curl --output-dir ./downloads -O https://h/f": nil, + "curl -- https://h": nil, + `curl "$url"`: nil, + `curl -o"$out" --output="$out" https://h`: nil, + }) +} + +func TestCommandsFindsWgetsWrites(t *testing.T) { + wantCommands(t, map[string][]string{ + "wget --post-data=x https://h": {"outward: wget"}, + "wget -qO- --post-data=x https://h": {"outward: wget"}, + "wget --post-file f https://h": {"outward: wget"}, + "wget --body-data x https://h": {"outward: wget"}, + "wget --body-file=f https://h": {"outward: wget"}, + "wget --method=PUT --body-file=f https://h": {"outward: wget"}, + "wget --body-data x --method POST https://h": {"outward: wget"}, + "wget --method DELETE https://h/x": {"outward: wget"}, + "wget --meth=POST https://h": {"outward: wget"}, + "wget --post-d=x https://h": {"outward: wget"}, + "wget -e method=POST https://h": {"outward: wget"}, + "wget -qemethod=put https://h": {"outward: wget"}, + "wget -e 'Post_Data = x' https://h": {"outward: wget"}, + "wget -e body_data=x https://h": {"outward: wget"}, + "wget -e BodyFile=f https://h": {"outward: wget"}, + "wget --execute=post-file=f https://h": {"outward: wget"}, + `wget -e "$setting" https://h`: {"outward: wget"}, + `wget -e "method=$method" https://h`: {"outward: wget"}, + }) +} + +func TestCommandsLetsWgetsReadsBe(t *testing.T) { + wantCommands(t, map[string][]string{ + "wget https://h/x": nil, + "wget -qO- https://h/x": nil, + "wget -nv -O - https://h/x": nil, + "wget --spider https://h": nil, + "wget --method=HEAD https://h": nil, + "wget -e 'method = get' https://h": nil, + "wget -e robots=off -r https://h": nil, + }) +} + +func TestCommandsFindsGhAPIsWrites(t *testing.T) { + wantCommands(t, map[string][]string{ + "gh api -X POST repos/o/r/dispatches": {"outward: gh api"}, + "gh api repos/o/r/issues -f title=x": {"outward: gh api"}, + "gh api repos/o/r/issues -F n=1": {"outward: gh api"}, + "gh api repos/o/r/issues --raw-field title=x": {"outward: gh api"}, + "gh api repos/o/r/issues --field n=1": {"outward: gh api"}, + "gh api repos/o/r/issues --input body.json": {"outward: gh api"}, + "gh api --method=PATCH repos/o/r": {"outward: gh api"}, + "gh api -XDELETE repos/o/r/git/refs/heads/x": {"outward: gh api"}, + "gh api graphql -X GET -f query=x": {"outward: gh api"}, + `gh api -X "$method" repos/o/r`: {"outward: gh api"}, + // gh reads -X=GET as GET, but the guard fails closed on it. + "gh api -X=GET repos/o/r": {"outward: gh api"}, + }) +} + +func TestCommandsLetsGhAPIsReadsBe(t *testing.T) { + wantCommands(t, map[string][]string{ + "gh api repos/o/r": nil, + "gh api -i repos/o/r": nil, + "gh api -X GET repos/o/r/pulls": nil, + "gh api repos/o/r/pulls -X GET -f state=all": nil, + "gh api --method head repos/o/r": nil, + "gh api --paginate repos/o/r/issues --jq '.[].title'": nil, + }) +} + +// TestCommandsSkipsTheValuesOfShortOptions checks that each short option that takes a value, as the +// client's help shows, takes the next word, which would write if it were an option. +func TestCommandsSkipsTheValuesOfShortOptions(t *testing.T) { + cases := map[string][]string{} + + for _, client := range []struct{ program, letters, option string }{ + {"curl", "AbcCDeEhHKmoPQrtuUwxyYz", "-XPOST"}, + {"wget", "aABDiIlnoOPQRtTUwX", "--method=POST"}, + {"gh api", "Hpqt", "-XPOST"}, + } { + cases[client.program+" "+client.option+" u"] = []string{"outward: " + client.program} + + for _, letter := range client.letters { + cases[fmt.Sprintf("%s -%c %s u", client.program, letter, client.option)] = nil + } + } + + wantCommands(t, cases) +} diff --git a/internal/guard/outward.go b/internal/guard/outward.go index 5b1b30d..984fdef 100644 --- a/internal/guard/outward.go +++ b/internal/guard/outward.go @@ -51,14 +51,15 @@ func git(args []string) (Command, bool) { } // gh returns gh's command in its arguments, named by its first two words, and whether it may act -// outside the Mac: any command but those that only read, or one that only the run decides. gh api -// counts as one that writes. +// outside the Mac: any command but those that only read, or one that only the run decides, and +// gh api if it writes. GraphQL posts its reads too, so gh api graphql counts as one that writes. func gh(args []string) (Command, bool) { switch { case len(args) == 0 || slices.Contains(ghReads, args[0]): return Command{}, false case args[0] == "api": - return Command{Outward, "gh api"}, true + return Command{Outward, "gh api"}, + slices.Contains(args, "graphql") || writes(ghAPI, args[1:]) default: return Command{Outward, name("gh", args[:min(2, len(args))]...)}, len(args) < 2 || !slices.Contains(ghReading, args[1]) ||