From a4e67007956ba404bf952733f062542d14a28758 Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Tue, 3 Nov 2026 21:04:33 +0100 Subject: [PATCH] feat(guard): find the commands that the guard watches internal/guard reads a bash call with internal/bash and gives each command that the guard watches, with its kind and name: outward (git's commands that send to another host, gh's commands but its reads, gh api, ssh, scp, sftp, acli, jira), control (tmux, osascript), another agent's CLI, baton with its command, and a program that only the run decides. It fails closed on a git command or option, or gh's first two words, that only the run decides. Decision 51 and fact 34 record the rules and git's and gh's facts. The guard now comes in three steps: guard, guard-http (curl, wget, gh api's reads) and guard-hook. --- docs/plan.md | 14 ++- internal/guard/guard.go | 92 ++++++++++++++++ internal/guard/guard_test.go | 202 +++++++++++++++++++++++++++++++++++ internal/guard/outward.go | 67 ++++++++++++ 4 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 internal/guard/guard.go create mode 100644 internal/guard/guard_test.go create mode 100644 internal/guard/outward.go diff --git a/docs/plan.md b/docs/plan.md index 10a5942..43e3969 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -51,8 +51,10 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `notes` | `baton handover` refuses a note in baton's folder, and `baton feature start` a brief, and the prompts send the notes and briefs to the session's `files/` folder and the roles' commands to the feature's worktree (decision 48; fact 29, risk 11) | Done (#26) | | `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) | In review (#31) | -| `guard` | `internal/guard`, the commands that act outside the Mac, and `baton hook guard` with its `preToolUse` hook in the plugin: in a role's pane, it denies outward actions, `tmux`, another agent's CLI, and the file tools' writes in baton's folder (decisions 9, 17 and 48; risk 11) | Todo | +| `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-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 | | `gates` | The engineer's handover runs the repo's gate command and refuses a red one | Todo | @@ -126,6 +128,7 @@ Checked on 2026-10-31 with Copilot CLI 1.0.105 and tmux 3.8, unless a fact says 31. **A role's flags** (on 2026-11-03, from `copilot --help` and the errors of a `copilot` given a bad flag, which it refuses before it starts a session): `--model `, a model's name or `auto`; `--reasoning-effort `, one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; `--max-ai-credits `, a number, fractions too, whose least value, 30 (fact 10), the CLI checks only after it has read its flags. A value that starts with a dash reads as a flag: `--max-ai-credits -1` fails on an unexpected argument `-1`. 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"`. ## 3. Design @@ -183,7 +186,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, `baton deliver` and `tmux`. 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`, 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: @@ -263,12 +266,13 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 47. **A stop hook sends back a `copilot` that stops while nothing of its role waits** (lead's call, flagged; facts 4 and 5). The plugin's `agentStop` hook runs `"$BATON_BIN" hook stop`, and `baton pane` gives each pane's `copilot` `BATON_BIN`, the path of its own baton, in place of any value of the user's, so the hook runs the baton that started the pane, not one from the `PATH` (decision 42). `baton wait` and `baton ask`, and `baton pane` while it waits, mark their role as waiting: a shared `flock` on `.wait` in the feature's folder, or on `orchestrator.wait` in baton's folder, files that baton never removes, as it never removes a lock (decision 40). The kernel releases the mark when its process ends, so a wait that crashed leaves none, and two waits of one role share it. A wait takes the mark only when its first look finds nothing, so a wait that refuses, or picks its work up at once, writes nothing; that look takes milliseconds, and a model's turn far longer, so the hook sees the mark of a wait that the turn started. `baton hook stop` lets the `copilot` stop while its role's mark is held, since the wait's end wakes it (fact 5). Otherwise it sends it back, with a reason that becomes its next prompt: to start `baton wait` when work can come to it, because another role holds the feature or its work waits there; to ask its question again when that waits for its answer, since baton doesn't ask it twice (decision 46); and otherwise to finish its part and hand over, or just to end its turn when a command of its own runs in the background. The orchestrator gets the first reason while a role holds a feature or its work waits in one, and may stop once it holds every feature with its work picked up, since those wait on the user, or when there is none. The hook changes nothing in baton's folder, and lets the `copilot` stop when its block has sent this turn back already (`stop_hook_active`), so a `copilot` that can't do what the reason says stops one turn later; that turn is also the cost for a role that stops to wait for a command of its own. It finds the repo from the `copilot`'s folder in its input, or from its own folder without one. A hook that fails lets the `copilot` stop (fact 4), so baton reports why on stderr and exits with 1, and copilot logs it. When two hooks look at one mark at the same moment, one can take the other's look for a wait and let its `copilot` stop, as a hook that fails does; only two `copilot`s of one role could do that. Rejected: looking for a `baton wait` in the process table, which needs `ps` and can't tell one pane's wait from another's; a PID file, which a crash leaves behind; and a mark that every wait takes at its start, which would write files for waits that refuse. 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 && strings.HasPrefix(args[0], "-") && !strings.Contains(args[0], bash.Dynamic) { + skip := 1 + if slices.Contains(gitValued, args[0]) { + skip = 2 + } + + args = args[min(skip, len(args)):] + } + + if len(args) == 0 { + return Command{}, false + } + + command := args[0] + + return Command{Outward, name("git", command)}, + slices.Contains(gitOutward, command) || strings.Contains(command, bash.Dynamic) +} + +// 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. +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 + default: + return Command{Outward, name("gh", args[:min(2, len(args))]...)}, + len(args) < 2 || !slices.Contains(ghReading, args[1]) || + strings.Contains(args[0], bash.Dynamic) + } +}