diff --git a/docs/plan.md b/docs/plan.md index 256b1b7..10a5942 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -50,8 +50,11 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `live-run` | A live run on a throwaway repo with a cheap model; the findings in a docs PR (facts 26 to 30) | Done (#25) | | `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 | In review (#30) | -| `guard` | `baton hook guard` and its `preToolUse` hook in the plugin: role panes can't make outward actions; the orchestrator's pane asks the user for them and can't edit the code (decisions 17 and 19) | Todo | +| `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 | +| `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 | | `jira` | `baton jira `: the ticket as text, from Jira's REST API with a read-only token in the Keychain (decision 6) | Todo | | `deliver-github` | `baton deliver` for GitHub: push the branch and open the pull request with `gh`, the lead's final report as its body | Todo | @@ -59,7 +62,7 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `deliver-bitbucket` | `baton deliver` for Bitbucket Cloud: push the branch and open the pull request with the REST API, a token in the Keychain | Todo | | `cost` | AI credits per role and per feature in `baton status`, from each session's events (decision 20), found by the session ID that `baton pane` gives `copilot` with `--session-id` and baton keeps | Todo | | `resume` | A pane whose `copilot` ended starts it again with `--resume`, and a pane that picked up its work but couldn't start `copilot` starts it (decision 38); the orchestrator gets again a question that its wait picked up but that has no answer yet (decision 46); the features carry on after a restart of the Mac | Todo | -| `homebrew` | A release tag and a formula in `codedogapp/homebrew-tap`; the docs install baton with Homebrew (decision 5) | Todo | +| `homebrew` | A release tag and a formula in `codedogapp/homebrew-tap`; the docs install baton with Homebrew (decision 5); the release carries the notices of the code built into baton, mvdan.cc/sh's BSD-3 license among them (decision 50) | Todo | Ideas for later, not planned: a read-only page of the runs, like zeroshot's; approvals through nudge when the user is away (open question 1); other agent CLIs; Linux. @@ -122,6 +125,7 @@ Checked on 2026-10-31 with Copilot CLI 1.0.105 and tmux 3.8, unless a fact says 30. **Live checks on a Mac.** `copilot` keeps its login in the login keychain, which macOS finds through `HOME`: under another `HOME`, `copilot` isn't logged in. A throwaway `COPILOT_HOME` is enough to keep its config and sessions apart; its package cache (`~/Library/Caches/copilot`) follows `HOME`. A pane that a command-line client makes (`new-session`, `split-window`, `respawn-pane`) gets that client's `PATH` in place of the session's (tmux's `spawn.c`): the role panes that `baton feature start` makes find what its shell finds, and a command typed in another shell carries that shell's `PATH` into a live check's panes. A pane's command runs with its shell's `-c`; a pane without one starts a login shell, whose `path_helper` (from `/etc/profile` or `/etc/zprofile`) puts `/etc/paths` and `/etc/paths.d` (Homebrew's `/opt/homebrew/bin`) before the rest of `PATH`. 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. ## 3. Design @@ -179,7 +183,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 only matches text, 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, `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). **State** (decision 15). Everything lives in `$(git rev-parse --git-common-dir)/baton/`, which every worktree of the repo shares and git never commits: @@ -259,11 +263,12 @@ 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 +} diff --git a/internal/bash/bash_test.go b/internal/bash/bash_test.go new file mode 100644 index 0000000..920de77 --- /dev/null +++ b/internal/bash/bash_test.go @@ -0,0 +1,193 @@ +package bash_test + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/codedogapp/baton/internal/bash" +) + +// calls returns the commands that the script runs, each as its words joined by spaces, with $ +// for each dynamic part. +func calls(t *testing.T, script string) []string { + t.Helper() + + found, err := bash.Calls(script) + if err != nil { + t.Fatalf("Calls(%q) says %v", script, err) + } + + lines := make([]string, len(found)) + for i, words := range found { + lines[i] = strings.ReplaceAll(strings.Join(words, " "), bash.Dynamic, "$") + } + + return lines +} + +// wantCalls checks the commands that each script runs. +func wantCalls(t *testing.T, cases map[string][]string) { + t.Helper() + + for script, want := range cases { + t.Run(script, func(t *testing.T) { + if got := calls(t, script); !slices.Equal(got, want) { + t.Errorf("Calls(%q) = %q, want %q", script, got, want) + } + }) + } +} + +func TestCallsFindsEveryCommand(t *testing.T) { + wantCalls(t, map[string][]string{ + "": nil, + "# git push": nil, + "git push": {"git push"}, + "cd x && git -C y push; make || true": {"cd x", "git -C y push", "make", "true"}, + "echo 'git push'": {"echo git push"}, + "echo \"$(gh pr create)\" `tmux ls`": {"echo $ $", "gh pr create", "tmux ls"}, + "diff <(curl -d x u) >(tee y)": {"diff $ $", "curl -d x u", "tee y"}, + "echo ${X:-$(gh pr merge)} $((1 + $(wc -l < f)))": { + "echo $ $", "gh pr merge", "wc -l", + }, + "cat < 0 { + own, found := wrappers[strings.TrimPrefix(Name(words[0]), "g")] + if !found { + return words + } + + letters, rest := options(words[1:], own.valued) + if strings.ContainsAny(letters, own.none) { + return nil + } + + for len(rest) > 0 && strings.Contains(rest[0], "=") { + rest = rest[1:] + } + + words = rest[min(own.operands, len(rest)):] + } + + return nil +} + +// scriptOf returns the script that the words run, if they run one: that of a shell's -c option, +// or eval's words, joined by spaces, as eval joins them. +func scriptOf(words []string) (string, bool) { + name := Name(words[0]) + if name == "eval" { + return strings.Join(words[1:], " "), true + } + + if !slices.Contains(shells, name) { + return "", false + } + + letters, args := options(words[1:], "oO") + if !strings.Contains(letters, "c") || len(args) == 0 { + return "", false + } + + return args[0], true +} + +// options reads the options that lead the words, as getopt does, and returns the letters of the +// short ones and the words after them. A letter in valued takes the rest of its word as its +// value, or else the next word. A long option, such as --name=value, holds its own value, and +// "--" and "-" hold none. +func options(words []string, valued string) (string, []string) { + var letters strings.Builder + + for len(words) > 0 && strings.HasPrefix(words[0], "-") { + word := words[0] + words = words[1:] + + if strings.HasPrefix(word, "--") { + continue + } + + own, next := cluster(word[1:], valued) + letters.WriteString(own) + + if next && len(words) > 0 { + words = words[1:] + } + } + + return letters.String(), words +} + +// cluster returns the letters of a word of short options, such as "xvf", up to the first that +// takes a value, and whether that one takes the next word, since its own word has none left. +func cluster(word, valued string) (string, bool) { + end := strings.IndexAny(word, valued) + if end < 0 { + return word, false + } + + return word[:end+1], end == len(word)-1 +}