From bec25651b24f0b39b4bb8367c2ab9fa3ae51b786 Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Wed, 4 Nov 2026 00:09:41 +0100 Subject: [PATCH] feat(cli): guard the orchestrator's pane In the orchestrator's pane, baton hook guard asks the user before outward commands and, while the new planApproval setting is on, before baton feature start; it lets the orchestrator's baton commands run, denies the other watched commands, and keeps create and edit in the session's folder. The orchestrator's copilot now runs with --allow-all (decision 54). --- README.md | 1 + docs/plan.md | 28 +-- internal/cli/guard.go | 161 +++++++++++------ internal/cli/guard_orchestrator.go | 122 +++++++++++++ internal/cli/guard_orchestrator_test.go | 143 +++++++++++++++ internal/cli/guard_session_test.go | 165 ++++++++++++++++++ internal/cli/guard_test.go | 57 +++--- internal/cli/pane.go | 10 +- internal/cli/pane_test.go | 6 +- internal/cli/settings_test.go | 6 +- internal/config/approval_test.go | 36 ++++ internal/config/config.go | 19 +- internal/config/config_test.go | 1 + internal/config/refusals_test.go | 13 +- internal/guard/guard.go | 17 +- internal/guard/guard_test.go | 5 + .../agents/orchestrator.agent.md | 1 + 17 files changed, 674 insertions(+), 117 deletions(-) create mode 100644 internal/cli/guard_orchestrator.go create mode 100644 internal/cli/guard_orchestrator_test.go create mode 100644 internal/cli/guard_session_test.go create mode 100644 internal/config/approval_test.go diff --git a/README.md b/README.md index aaf468f..9f036eb 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ baton needs no config file. To change its settings, write `config.json` in `~/Li - `roles`: a section per role, `orchestrator`, `lead`, `engineer`, `qa` or `reviewer`, with its `model` (by default `claude-opus-5.5`), its `reasoningEffort` (`none`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`; by default copilot's own), its `maxAiCredits` (30 or more; by default no cap) and, for the orchestrator alone, its `extraTools`: your own tools that it may use, by default nudge's `nudge-ask` and `nudge-report`. - `roundCap`: the engineer's passes per brief before the orchestrator asks you (by default 3). - `branchPrefix`: what goes before a feature's slug in its branch's name (by default `feature/`). +- `planApproval`: whether baton asks you before each feature starts, so that you can check the orchestrator's plan first (by default `true`). For example, QA on another model with a cap, for every repo, and two rounds with another branch prefix for one repo: diff --git a/docs/plan.md b/docs/plan.md index 12264f4..eaecd6b 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -55,8 +55,8 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `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) | Done (#33) | | `guard-hook` | `baton hook guard` with its `preToolUse` hook in the plugin: in a role's pane, it denies, with a reason for the role, a bash call that the guard can't read or that runs a command that the guard watches, but baton's commands for a role (`baton wait`, `handover`, `ask`, `status` and help), and a `create` or `edit` of a file in baton's folder, however its path names it; it says nothing in the orchestrator's pane yet (decisions 9, 17, 48 and 52; fact 36, risk 11) | Done (#34) | -| `plugin-check` | `baton pane` checks the files of the plugin folder that it finds in its cache before each `copilot` starts, a role's after its wait too, and writes the plugin under the next name beside a changed folder; `baton hook guard` denies a role's `create` and `edit` in baton's cache, so that a role can't turn the guard off for the panes that start after it (decisions 36, 52 and 53, risk 12) | In review (#35) | -| `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 | +| `plugin-check` | `baton pane` checks the files of the plugin folder that it finds in its cache before each `copilot` starts, a role's after its wait too, and writes the plugin under the next name beside a changed folder; `baton hook guard` denies a role's `create` and `edit` in baton's cache, so that a role can't turn the guard off for the panes that start after it (decisions 36, 52 and 53, risk 12) | Done (#35) | +| `guard-orchestrator` | In the orchestrator's pane, `baton hook guard` asks the user before each outward action, and before each `baton feature start` while `planApproval`, a new setting, is on; it lets baton's commands for the orchestrator run, denies the other commands that it watches, and denies `edit` and `create` outside the session's folder; the orchestrator runs with `--allow-all` (decisions 8, 19, 45 and 54; fact 37) | In review (#36) | | `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 | @@ -132,6 +132,7 @@ Checked on 2026-10-31 with Copilot CLI 1.0.105 and tmux 3.8, unless a fact says 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. 36. **The tools' arguments in a `preToolUse` hook** (Copilot CLI 1.0.105, on 2026-11-03, checked live). The hook's input names the tool in `toolName` and gives its arguments in `toolArgs`, which the hooks reference types as `unknown`, as a JSON object: for `bash`, `command` and `description`, with `mode` and, for an async call, `initial_wait` and `shellId` (the agent-wake spike's hook log); for `create`, `path` and `file_text`; for `edit`, `path`, `old_str` and `new_str`, the paths absolute, as the CLI's tool schemas ask. The input also has `sessionId`, `timestamp` and `cwd`. The answer `{"permissionDecision":"deny","permissionDecisionReason":…}` stops the tool, under `--allow-all` too, and the agent gets `Denied by preToolUse hook: ` with the reason, which a denial needs. The live check ran `copilot -p` with Claude Haiku 5.5, `--allow-all`, the plugin of `guard-hook` and a throwaway `COPILOT_HOME`, in a throwaway repo, for 0.21 AI credits: `baton hook guard` denied `tmux -V` and a `create` in baton's folder, and said nothing to a `create` and an `edit` of a file in the repo, which ran. Of a role's tools (decision 10), `bash`, `create` and `edit` write; `view`, `grep`, `glob` and `web_fetch` read; and `read_bash`, `stop_bash` and `list_bash` look after the shells that `bash` starts. +37. **The user's answer to a `preToolUse` hook's `ask`** (Copilot CLI 1.0.105, on 2026-11-03, checked live). In an interactive session under `--allow-all`, the answer `{"permissionDecision":"ask","permissionDecisionReason":…}` holds the tool and shows the "Hook permission request" of fact 4 in the pane: the reason, the tool's name and its arguments as JSON, and "Do you want to allow this tool call?". Yes runs the tool; No is a field for what the agent should do instead, which does nothing while it's empty. After a No, the agent gets `Denied by user via preToolUse hook prompt: ` with the reason, and `The user provided the following feedback: ` with the text; copilot puts a full stop after the reason, so a reason that ends in one shows two. The input's `sessionId` is the name of the session's folder in `$COPILOT_HOME/session-state/`. Without `COPILOT_ALLOW_ALL`, the folder-trust question still shows under `--allow-all` (fact 9), and the footer shows `Allow All`, where fact 27's showed `Manual Approval`. The live check ran the orchestrator's pane with Claude Haiku 5.5, the plugin of `guard-orchestrator` and a throwaway `COPILOT_HOME`, in a throwaway repo without a remote, on a tmux server of its own, for 0.32 AI credits: the guard asked about `git push --dry-run`, which the check declined with feedback, and about `baton feature start`, which it allowed, and which ran; a `create` in the session's `files/` folder ran, and one in the repo was denied. ## 3. Design @@ -174,22 +175,22 @@ copilot --agent baton: --plugin-dir /plugin- --model `copilot` keeps the pane's terminal and environment, so the role's `baton` commands read `BATON_ROLE` and `BATON_FEATURE` there, and gets `COPILOT_ALLOW_ALL=true` too, which trusts the worktree for that session (decision 39), and `BATON_BIN`, the path of the pane's baton, which the plugin's `agentStop` hook runs (decision 47). The plugin comes from the binary: `baton pane` writes it into ``, the `baton` folder in the user's cache folder (`~/Library/Caches/baton` on a Mac), unless it's there already, unchanged, in a folder named by a hash of its files (decision 36); a role's pane checks it again after the wait (decision 53). baton keeps each role's session ID, so it can find the role's events and resume the session. -**The orchestrator's session** (decisions 44, 45 and 49). `baton start`, or plain `baton`, in the user's shell, makes the tmux session `baton-` in the repo's main worktree, unless it runs already, with one pane titled orchestrator, which runs `baton pane` with `BATON_ROLE=orchestrator` and no `BATON_FEATURE`. It reads the config file before it makes the session, so a mistake there shows in the user's terminal, not in a pane that takes its session with it when it fails. Then it shows the session: outside tmux, baton becomes `tmux attach-session`, and inside tmux, it switches the user's client to the session. The orchestrator's `baton pane` writes the plugin, reads its settings, and becomes its `copilot` at once, with no first prompt, since the user speaks first, and with the user's environment, plus `BATON_BIN` (decision 47): +**The orchestrator's session** (decisions 44, 45, 49 and 54). `baton start`, or plain `baton`, in the user's shell, makes the tmux session `baton-` in the repo's main worktree, unless it runs already, with one pane titled orchestrator, which runs `baton pane` with `BATON_ROLE=orchestrator` and no `BATON_FEATURE`. It reads the config file before it makes the session, so a mistake there shows in the user's terminal, not in a pane that takes its session with it when it fails. Then it shows the session: outside tmux, baton becomes `tmux attach-session`, and inside tmux, it switches the user's client to the session. The orchestrator's `baton pane` writes the plugin, reads its settings, and becomes its `copilot` at once, with no first prompt, since the user speaks first, and with the user's environment, plus `BATON_BIN` (decision 47): ```sh copilot --agent baton:orchestrator --plugin-dir /plugin- --model \ - [--reasoning-effort ] [--max-ai-credits ] \ + [--reasoning-effort ] [--max-ai-credits ] --allow-all \ --available-tools bash read_bash stop_bash list_bash view create edit grep glob web_fetch \ ask_user ``` -It keeps copilot's own prompts, since the user watches that pane, `ask_user`, since the user talks to it, and its extra tools from the config file, by default the user's nudge tools, for when the user is away. +It runs with every permission, as the roles do, since baton's guard asks the user what needs asking (decision 54), but keeps `ask_user`, since the user talks to it, and copilot's folder-trust question, since the user watches that pane, and its extra tools from the config file, by default the user's nudge tools, for when the user is away. **Handover and wake-up** (decision 12). `baton handover --note [--verdict …]` checks the move, stores the note as `notes/NN--to-.md`, logs the event and makes the next role the holder. It refuses a note in baton's folder, which only baton writes: the roles write theirs in their session's `files/` folder (decision 48). The role then starts `baton wait` in a background shell and ends its turn. `baton wait` looks for the role's work every second (decision 31); once the role has some, it records the pickup, prints the work (the round, the note's writer and their verdict, and the note's path) and exits; its end wakes the role (fact 5). The orchestrator's pane names no feature (decision 44): its `baton wait` picks up the work of any feature that it holds, ready or escalated, and its `baton handover` names the feature and the next role. The `agentStop` hook is the safety net (decision 47): `baton wait` and `baton ask` mark their role as waiting while they wait, and a `copilot` that ends its turn without that mark gets one more turn that tells it what to do: start `baton wait` when work can come to it, ask its question again when that waits for its answer, and finish its part and hand over otherwise. It gets that turn once per stop (`stop_hook_active`); the orchestrator gets it while a role holds a feature or work waits for it. Typing into the pane stays the fallback (fact 12). **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` and `wget`, Jira writes and `ssh`; and `tmux`, `osascript`, another agent's CLI, baton's commands but a role's own (`baton wait`, `handover`, `ask` and `status`), a program that only the run decides, and `edit` and `create` in baton's folder and in its cache, which holds the plugin (decisions 51 to 53). It stops a role's mistakes, not a role that sets out to get past it (risks 2, 11 and 12). 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, 39 and 54). 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 and `ssh`; and `tmux`, `osascript`, another agent's CLI, baton's commands but a role's own (`baton wait`, `handover`, `ask` and `status`), a program that only the run decides, and `edit` and `create` in baton's folder and in its cache, which holds the plugin (decisions 51 to 53). It stops a role's mistakes, not a role that sets out to get past it (risks 2, 11 and 12). In the orchestrator's pane, which the user watches, it answers `ask` for outward actions, so the user approves each one, and for `baton feature start` while plan approval is on (decision 8); it denies `tmux`, `osascript`, another agent's CLI and baton's commands that aren't the orchestrator's; 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 (decision 54). 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). The orchestrator, too, runs with `--allow-all`, so the user answers only the guard's questions, but it keeps `ask_user` and copilot's folder-trust question (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: @@ -201,7 +202,7 @@ It keeps copilot's own prompts, since the user watches that pane, `ask_user`, si Every change takes an exclusive lock on the feature (`flock` on its `lock` file, which the kernel releases when the process ends), writes a new file and renames it over the old one, so a reader, who takes no lock, sees the old state or the new one. Every change logs its event, and keeps its note if it has one, in the same hold of the lock (decision 26). -**Config** (decisions 16 and 49). `baton` reads `config.json` in its folder under `os.UserConfigDir()` (`~/Library/Application Support/baton/` on a Mac), so nothing goes into a work repo. Its top holds the settings for every repo, and `repos` a section per repo, keyed by the `origin` remote's URL without its scheme, user, port and `.git` (fact 32); the repo's section lies over the top, and the top over baton's own settings, one setting at a time. A repo without an `origin` remote gets the top's settings. The settings so far: per role (`roles`), the model, the reasoning effort and an AI credits cap, and the orchestrator's extra tools (nudge's, by default); the round cap (3); the branch prefix (`feature/`). `baton pane`, `baton feature start` and `baton start` read the file and check all of it, before they wait or make anything. The later steps add theirs: the gate command (`gates`), plan approval, on by default (`guard`), the pull request host, by default from the remote's URL, and the base branch, by default the remote's default branch (`deliver-github`), and Jira's site and email (`jira`). Tokens never go into the file: they live in the login Keychain. For example, QA on another model for every repo, and two rounds with another branch prefix for one repo: +**Config** (decisions 16 and 49). `baton` reads `config.json` in its folder under `os.UserConfigDir()` (`~/Library/Application Support/baton/` on a Mac), so nothing goes into a work repo. Its top holds the settings for every repo, and `repos` a section per repo, keyed by the `origin` remote's URL without its scheme, user, port and `.git` (fact 32); the repo's section lies over the top, and the top over baton's own settings, one setting at a time. A repo without an `origin` remote gets the top's settings. The settings so far: per role (`roles`), the model, the reasoning effort and an AI credits cap, and the orchestrator's extra tools (nudge's, by default); the round cap (3); the branch prefix (`feature/`); plan approval (on). `baton pane`, `baton feature start` and `baton start` read the file and check all of it, before they wait or make anything, and so does `baton hook guard` in the orchestrator's pane, for a script that starts a feature (decision 54). The later steps add theirs: the gate command (`gates`), the pull request host, by default from the remote's URL, and the base branch, by default the remote's default branch (`deliver-github`), and Jira's site and email (`jira`). Tokens never go into the file: they live in the login Keychain. For example, QA on another model for every repo, and two rounds with another branch prefix for one repo: ```json { @@ -227,7 +228,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 5. **Homebrew, `codedogapp/tap/baton`** (lead's call): a formula that builds baton from its tag's source, as nudge's did. The tap's state is to be checked at the `homebrew` step, since nudge left it. 6. **Jira's REST API with a scoped, read-only token** (lead's call): nothing else to install, and no Rovo credits. The token (`read:jira-work`) lives in the login Keychain, which the user fills. 7. **Claude Opus 5.5 for every role** (lead's call), configurable per role. A weaker engineer ignored rejections in zeroshot's spike (fact 16). -8. **Plan approval on** (lead's call), configurable. The guard makes the user approve each `baton feature start` in the orchestrator's pane, so an orchestrator can't skip it. +8. **Plan approval on** (lead's call), configurable. The guard makes the user approve each `baton feature start` in the orchestrator's pane, so an orchestrator can't skip it (decision 54); `"planApproval": false` in the config file turns that off (decision 49). 9. **The roles run with `--allow-all` and `--no-ask-user`, guarded by a hook** (lead's call, flagged): the CLI's own prompts would stop a role in a pane nobody watches, and a role can't approve its own tools. The guard keeps outward actions out of the role panes (fact 4), and the orchestrator, whom the user watches, keeps `ask_user`. 10. **A tool allow-list per role (`--available-tools`)** (lead's call): it removes `task` and every other tool that runs hidden agents, the user's skills and MCP tools, and it fails closed when a new CLI version adds such a tool (fact 7). An exclude list would fail open. 11. **The agents and hooks ship as a plugin** (lead's call): the binary embeds the plugin, writes it to its cache and passes it with `--plugin-dir`, so `~/.copilot/` stays untouched and the agents are `baton:` (fact 3), clear of the user's own agents. @@ -238,7 +239,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 16. **The config is JSON** (lead's call), where the proposal had TOML: Go's standard library reads JSON, and nudge's settings file is JSON too. It lives outside the repo, keyed by the remote's URL. 17. **Outward actions only through `baton deliver`, in the orchestrator's pane, on the user's approval** (lead's call). The roles never push; the lead commits locally. 18. **Roles ask the orchestrator, never the user** (the user's rule): `baton ask` blocks the role until `baton answer`. -19. **The orchestrator can't write the code** (lead's call): the guard denies its `edit` and `create` outside its session's folder, where it writes its briefs and notes (fact 15; decision 48). +19. **The orchestrator can't write the code** (lead's call): the guard denies its `edit` and `create` outside its session's folder, where it writes its briefs and notes (fact 15; decisions 48 and 54). Its `bash` can still write (risk 3). 20. **Cost from the session's own events** (lead's call): the last `session.usage_checkpoint` per role's session (fact 8), read while the feature runs; no extra flags. 21. **Waits poll every second and compare wall-clock times** (lead's call): simple, and right after the Mac sleeps (fact 17). No file-watching dependency. 22. **Branches are deleted on merge** (lead's call): like Bakhyard, unlike nudge, as the user asked when baton started. The `main` ruleset is nudge's: no deletion, no force push, changes only through pull requests (no approvals needed), and the required checks; the wiki is off, as in nudge. @@ -264,21 +265,22 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 42. **`baton feature start` runs in the orchestrator's pane or the user's shell, and the panes run the baton that started the feature** (lead's call). It refuses a role's pane, by `BATON_ROLE` (decision 29): roles don't start features. Each pane's command is that baton's own path (`os.Executable`), not `baton` from the tmux server's `PATH`, which the panes get and which may find another baton, or none, so a feature's panes run the baton that started it. On a Mac, that path is the one that started baton, such as Homebrew's link; on Linux, it has its symlinks resolved. baton stops on a hangup (SIGHUP) as on Ctrl-C, so a terminal or pane that closes mid-start undoes the start, the branch included, which git alone would keep (fact 23). Under `nohup`, which starts baton with the hangup ignored, baton leaves it ignored, and so does git, but not for its hooks: a hangup while a hook of the user's runs fails the start, and baton undoes it. The order leaves two races. The lead's pane picks the brief up as soon as the session has that pane, before tmux has made the others, and its `copilot` starts; if tmux then fails, baton ends the session and removes the feature all the same. And Ctrl-C during `new-session` itself may leave the session (decision 32), with the lead's pane, which may have picked the brief up before baton removed the feature, and then works in a worktree that's gone, until the user ends the session. 43. **The walking skeleton runs this test binary as baton, with a stand-in `copilot` in each pane** (lead's call). `TestSkeleton`, in the root package, puts two links first on the `PATH`: `baton`, to the test binary, which runs baton's `main` in place of the tests when `BATON_TEST_AS_BATON` is set, and `copilot`, to `testdata/copilot`, a shell script that plays its pane's role through one round with set notes and verdicts and logs the work it gets. So the test builds nothing and calls no model, and it runs what the user runs: `main`, `baton feature start`, each pane's `baton pane`, and the roles' `baton handover` and `baton wait`. It checks what the orchestrator's commands print, the panes' titles and commands, the feature's journal and notes, the work that each pane got, and the commit. baton uses tmux's default socket, so the test, as a smoke run does, gives tmux a `TMUX_TMPDIR` of its own, under `/tmp`, since a Mac allows a socket's path 104 bytes at most, and starts that server first, with `-f /dev/null`; it also gets a `HOME` and a `TMPDIR` of its own. The server's first pane ends the server if the test binary dies before its cleanup, as when mise stops the tests because another task failed. The `live-run` step tries the real `copilot`. 44. **The orchestrator's pane names no feature** (lead's call). The orchestrator works on all the repo's features, so its pane has `BATON_ROLE` and no `BATON_FEATURE`, and baton refuses an orchestrator's pane that names a feature, and a role's pane that names none (decision 29). The orchestrator's `baton wait` looks at every feature, every second, in the order of their slugs: it picks up the work of the first that it holds, ready or escalated, that it hasn't picked up yet, under that feature's lock, as a role's wait does (decision 31), and prints it with the feature's status; in a feature that a role holds, its work is the role's question (decision 46). It refuses when no work could come, since its wait would never end: when there's no feature, or when it holds them all. So when the user starts a feature from their own shell, the orchestrator learns of it from `baton status`, and its next `baton wait` covers it. Its `baton handover` names the feature and the next role, `baton handover --to --note `: the lead, with a new brief, which starts the rounds again, or, after an escalation, the engineer, for one more round (decision 13). A role's handover takes neither: its pane names the feature, and baton picks the next role. -45. **`baton start` opens the orchestrator in the user's terminal, and the orchestrator keeps copilot's own prompts** (lead's call, flagged). `baton start`, or plain `baton`, runs in the user's own shell, and refuses a pane that baton started, by `BATON_ROLE` (decision 29). From any of the repo's worktrees, it names the session `baton-` after the main worktree's folder, as a feature's session is named (decision 41), and makes it in the main worktree, unless it runs already. A session of that name in another folder belongs to another repo with a folder of the same name, or to the user, so baton refuses it rather than show it. Then baton shows the session in the user's terminal: outside tmux, it becomes `tmux attach-session` (`execve`, as `baton pane` becomes `copilot`, decision 38), so tmux keeps the terminal until the user detaches; inside tmux, it switches the user's client, the one that shows baton's pane, to the session (fact 22). With no client to switch, baton fails with tmux's error, and the session stays for the next `baton start`. Two `baton start`s at once in one repo: one makes the session, and the other fails on tmux's `duplicate session`. The orchestrator's `copilot` starts at once, with no first prompt, since the user speaks first. It keeps copilot's own prompts: no `--allow-all`, `--no-ask-user` or `COPILOT_ALLOW_ALL`, since the user watches that pane and approves each tool, until the `guard` step decides what the orchestrator may do unasked; so copilot's folder-trust question for the main worktree is the user's to answer too, once. It gets the roles' tools (decision 10), plus `ask_user` and its extra tools from the config file, by default the user's nudge tools, `nudge-ask` and `nudge-report` (decision 49); for a user without nudge, `copilot` only says that those names are unknown (fact 7), and `"extraTools": []` leaves them out. Its prompt, like the roles', names only the commands that baton has (decision 37): it plans with the user, starts features, keeps one `baton wait` running, reports each ready or escalated feature, and hands features back on the user's word. +45. **`baton start` opens the orchestrator in the user's terminal, and the orchestrator keeps copilot's own prompts** (lead's call, flagged). `baton start`, or plain `baton`, runs in the user's own shell, and refuses a pane that baton started, by `BATON_ROLE` (decision 29). From any of the repo's worktrees, it names the session `baton-` after the main worktree's folder, as a feature's session is named (decision 41), and makes it in the main worktree, unless it runs already. A session of that name in another folder belongs to another repo with a folder of the same name, or to the user, so baton refuses it rather than show it. Then baton shows the session in the user's terminal: outside tmux, it becomes `tmux attach-session` (`execve`, as `baton pane` becomes `copilot`, decision 38), so tmux keeps the terminal until the user detaches; inside tmux, it switches the user's client, the one that shows baton's pane, to the session (fact 22). With no client to switch, baton fails with tmux's error, and the session stays for the next `baton start`. Two `baton start`s at once in one repo: one makes the session, and the other fails on tmux's `duplicate session`. The orchestrator's `copilot` starts at once, with no first prompt, since the user speaks first. It keeps copilot's own prompts: no `--no-ask-user` or `COPILOT_ALLOW_ALL`, since the user watches that pane, so copilot's folder-trust question for the main worktree is the user's to answer, once. Until `guard-orchestrator`, it ran without `--allow-all` too, so the user approved each tool; now it runs with `--allow-all`, and the guard asks the user only about the commands that need it (decision 54). It gets the roles' tools (decision 10), plus `ask_user` and its extra tools from the config file, by default the user's nudge tools, `nudge-ask` and `nudge-report` (decision 49); for a user without nudge, `copilot` only says that those names are unknown (fact 7), and `"extraTools": []` leaves them out. Its prompt, like the roles', names only the commands that baton has (decision 37): it plans with the user, starts features, keeps one `baton wait` running, reports each ready or escalated feature, and hands features back on the user's word. 46. **A role's question waits in its feature's file, one at a time** (lead's call, flagged). The holder's last question sits at the top of `feature.json`, so it keeps the format (decision 24): the role that asked, the text, whether the orchestrator's `baton wait` picked it up, and the answer once it comes. Not in `questions/.json`, as planned: a question belongs to its feature, so it takes the feature's lock and goes in its journal, with its text, as every change does (decision 26), and the slug names it, so `baton answer ` needs no ID. Only the holder asks, since only the holder works; the orchestrator asks the user. A role asks one question at a time, and baton refuses its handover until the answer comes, since the answer goes to the role that asked, which should still hold the feature when it acts on it. `baton ask` stores the question under the lock, then reads the file every second without it, as any reader may, until the answer comes. An ask with the text of the role's last question doesn't ask it again: it waits for the answer, or prints it if it came, so a role whose ask ended early, in a crash or a restart, gets the answer by asking again, and the orchestrator doesn't get the question twice. The holder's handover clears its question, which the journal keeps, so the same question in a later turn is a new one; the cost is that a role that means to ask the same question twice in one turn gets the first answer. If another question takes the place of an ask's question before the ask reads the answer, as two asks at once could, that ask fails rather than print an answer to another question, and so does an ask whose question a handover cleared before it read the answer. The orchestrator's `baton wait` picks up a question in any feature that a role holds, in the order of the slugs, as it picks up its work (decision 44): it marks the question picked under the lock and logs a pickup, so its next wait doesn't print it again; a question that it picked up but didn't answer, as when its pane stopped, waits for the `resume` step to bring it back. `baton answer` runs only in the orchestrator's pane, while a question waits for its answer. The texts are the commands' arguments, each one whole and not blank, so the prompts tell the roles and the orchestrator to quote them for the shell. 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 past this refusal, which stops only the note that a handover names; the guard's `preToolUse` hook keeps the file tools out of baton's folder, in any case (decision 52), but not `bash` (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. +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; `planApproval`, `true` or `false`, keeps the one below only when the layer leaves it out or gives `null`, so `false` turns plan approval off; 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), `feature/` (decision 41) and plan approval on (decision 8). 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; `baton hook guard`, in the orchestrator's pane, reads it for a script that starts a feature (decision 54). 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 < .git/baton/…` (risk 11), and to the binary that `BATON_BIN` names, which the hooks run. It misses a hard link, outside baton's folder, to a file in it, which the walk up the link's folders can't see; data that `web_fetch` sends in a URL, as it only reads; other repos' baton folders; and what decisions 50 and 51 list. It denies the file tools in the plugin's cache too, and `baton pane` checks the plugin's files there before each `copilot` starts (decision 53), but `bash` can still change them while a `copilot` starts or runs (risk 12). Risk 2's second belt (`belt`) is for the rest. +52. **`baton hook guard` denies a role's tools what the guard watches** (lead's call, flagged; decisions 9, 17, 48 and 51, facts 4 and 36, risks 2, 4, 11 and 12). The plugin's `preToolUse` hook runs `baton hook guard` before each tool that a pane's `copilot` calls. In a role's pane, it denies a `bash` call whose script the guard can't read (decision 50), or that runs a command that the guard watches, but baton's commands for a role: `baton wait`, `baton handover`, `baton ask`, `baton status` and baton's help (`baton help`, `-h`, `-help` and `--help`); the guard names a `baton` command by its first word, or by its first two for `baton feature` and `baton hook`, such as `baton feature start`, so `baton wait -h` passes and `baton answer -h` doesn't. It denies a `create` or an `edit` of a file in baton's folder, which only baton writes (decision 48): it makes the path absolute from the input's `cwd`, resolves the symlinks of the part of the path that is there, and walks up its folders, comparing each with baton's folder as a file (`os.SameFile`), so that neither a symlink, nor a disk that ignores case, nor a new file or folder gets past; in a repo without baton's folder, no path is baton's. Each reason starts with "baton's guard stopped this call, and none of it ran." and says why: the first command that the guard watches, by its name, with its kind's rule (an outward action is the orchestrator's, with the user's approval, so the role keeps its work local and says in its handover note what should go out; a role never drives tmux or the Mac's apps, nor starts another agent; a program must be named in the script itself), the parser's error, with a plea for plain bash, or the file and baton's folder, with the session's `files/` folder for notes. The hook denies the whole call, since a bash call is one script, and the role can try again. It answers only `deny`, and otherwise says nothing, which leaves the tool to `copilot`'s own permissions; the role's other tools only read or look after its shells (fact 36). In the orchestrator's pane, it follows decision 54. When it can't decide, as for input that isn't a JSON object, arguments that don't fit the tool, a pane without `BATON_ROLE` or a folder outside a git repo, it fails, so `copilot` denies the tool (fact 4). It reads only its input and, for a file tool, git's common folder, so it answers well within the hook's timeout, past which `copilot` would allow (risk 4). The guard stops a role's mistakes, not a role that sets out to get past it. It misses `bash`'s own writes, which it doesn't follow: in baton's folder, such as `echo … > .git/baton/…` (risk 11), and to the binary that `BATON_BIN` names, which the hooks run. It misses a hard link, outside baton's folder, to a file in it, which the walk up the link's folders can't see; data that `web_fetch` sends in a URL, as it only reads; other repos' baton folders; and what decisions 50 and 51 list. It denies the file tools in the plugin's cache too, and `baton pane` checks the plugin's files there before each `copilot` starts (decision 53), but `bash` can still change them while a `copilot` starts or runs (risk 12). Risk 2's second belt (`belt`) is for the rest. 53. **baton checks the plugin's folder before each `copilot` starts** (lead's call, flagged; decisions 36, 38 and 52, risk 12). `plugin.Write` hashes the files of a folder that has the plugin's name, by their paths in the folder and their contents, as it hashes the binary's, and uses the folder only if the hashes match and it is a folder, not a symlink to one. A folder with other files, more, fewer or changed ones, or with anything but folders and regular files, such as a symlink, and anything else under the name, it leaves as it is, and writes the plugin under the next name, `plugin--1`, then `-2`, up to `-7`; with all eight names taken, the pane fails and says why. baton never replaces a folder (Go's `os.Rename` refuses to), so a starting `copilot` never misses the plugin's folder, as it could while a swap ran, and goes on without the hooks and with the default agent (risk 10). Panes that start at once take the same new name, since a folder appears whole (decision 36). A role's pane checks again after its wait, which can last hours, just before it becomes `copilot`; a failure there leaves the work picked up, as a `copilot` that fails to start does (decision 38). The guard denies a role's `create` and `edit` in baton's cache folder, as in baton's folder, and fails without the user's cache folder (decision 52). `bash` can still change the plugin's files after a pane's check, while its `copilot` starts or runs, or through a hard link (risk 2), and a Finder `.DS_Store` in a folder costs one more folder. Rejected: rewriting a changed folder, which a starting `copilot` could see half written; removing it, which a running pane may still use; and making a checked folder read-only, which a role that runs as the user can undo. +54. **`baton hook guard` asks the user in the orchestrator's pane** (lead's call, flagged; decisions 8, 17, 19, 45, 48, 49 and 52, facts 4, 28 and 37, risks 2 and 3). The orchestrator's `copilot` runs with `--allow-all`, so its tools run unasked, and the guard answers `ask` where the user should decide, which copilot shows in the pane with the reason and the call, for the user to allow, or to decline with a word for the orchestrator (fact 37); the orchestrator's prompt tells it to ask the user what they want instead. For `bash`, the guard reads the script's commands (decision 51). Baton's commands for the orchestrator run unasked: `baton wait`, `baton answer`, `baton handover`, `baton status` and baton's help. `baton feature start` asks while plan approval is on (decision 8), so the user checks the plan first, and runs unasked once `planApproval` is `false` (decision 49); the guard reads the config file only for a script that starts a feature, for the repo of the hook's folder, and a mistake in the file, or a folder outside a repo, fails the hook, so `copilot` denies the script (fact 4). An outward command asks, so the user approves each push and each pull request, until `baton deliver` comes (decision 17). The guard denies, with a reason for the orchestrator, `tmux` and `osascript`, another agent's CLI, baton's other commands, such as `baton ask` or `baton pane`, and, as in a role's pane (decision 52), a program that only the run decides and a script that it can't read. A script runs whole or not at all: a command that the guard denies denies the script, and otherwise the first command that asks names the question, so the user's yes runs the whole script. The guard names baton's `feature` and `hook` commands by two words (decision 52), so that `baton feature start` asks and a later `baton feature close` can differ. For `create` and `edit`, it denies a path outside the session's folder, `session-state/` in copilot's home folder, which `COPILOT_HOME` names in the pane, or else `~/.copilot`, with the walk of decision 52; the input's `sessionId` names the session (fact 37). A session ID that isn't one folder's name fails the hook, and a session's folder that isn't there holds nothing. The orchestrator keeps `ask_user` and copilot's folder-trust question (decision 45). The guard misses what it misses in a role's pane (decision 52): the orchestrator can write any file with `bash`, and run local commands, such as `rm`, unasked (risk 3), and `web_fetch` can send data in a URL. Its extra tools, such as nudge's, run unasked, and send what they send. The guard reads the plan approval of the hook's folder's repo, not of a repo that the script `cd`s into, and `baton feature start -h` asks too. Rejected: denying outward commands in this pane until `baton deliver` comes, which would leave the user no way to push from it; asking about every command, as copilot did before (decision 45), which buries the questions that matter among the rest; and copilot's `--deny-tool` rules, which only deny (fact 28). ## 5. Risks 1. **Wake-ups aren't a documented contract** (fact 5). A CLI update could break them. The stop hook and typing into the pane are the fallbacks; `live-run` saw the whole loop work with CLI 1.0.105 (fact 26). 2. **The guard sees only what a command shows** (decisions 9, 50 and 51): a role can reach the network in ways that it can't see (a script file, another language, a tool that it doesn't know, git's aliases). It is a seatbelt, not a sandbox. Taking the role panes' credentials away (an empty `GH_CONFIG_DIR`, a git `pushInsteadOf` that fails) would add a second belt; `belt` checks whether that works. -3. **`--allow-all` lets a role read and write any path** on the Mac. Roles work in their worktree by their instructions, and the guard watches outward actions only. +3. **`--allow-all` lets a role, and the orchestrator, read and write any path** on the Mac, and run local commands, such as `rm`, unasked. Roles work in their worktree by their instructions, and the orchestrator in its session's folder; the guard keeps the file tools out of baton's folders in a role's pane, and in the session's folder in the orchestrator's (decisions 52 and 54), and watches outward actions, but doesn't follow `bash`'s writes. 4. **A hook that times out allows** (fact 4): the guard must answer within its timeout and never wait on anything. 5. **AI credits**: four Opus sessions per feature, three of them in every round, plus the orchestrator. The per-role cap (`maxAiCredits` in the config, for `--max-ai-credits`, decision 49) and `baton status` keep it visible; the round cap bounds it. 6. **The user's own instructions apply in every pane**: Copilot CLI loads the user's and the repo's custom instructions, which may disagree with a role's prompt. diff --git a/internal/cli/guard.go b/internal/cli/guard.go index ab6ec09..9cfa48c 100644 --- a/internal/cli/guard.go +++ b/internal/cli/guard.go @@ -30,32 +30,49 @@ var why = map[guard.Kind]string{ "the program in the script itself.", } +// helpCommands are baton's help, by the names that the guard gives them, which every pane runs. +var helpCommands = []string{"baton help", "baton -h", "baton -help", "baton --help"} + // roleCommands are baton's commands that a role runs, by the names that the guard gives them. -var roleCommands = []string{ - "baton ask", "baton handover", "baton status", "baton wait", - "baton help", "baton -h", "baton -help", "baton --help", -} +var roleCommands = slices.Concat( + []string{"baton ask", "baton handover", "baton status", "baton wait"}, helpCommands) -// guardInput is what baton reads of the preToolUse hook's input: the copilot's folder, and the -// tool that the copilot calls, with its arguments (facts 4 and 36). +// guardInput is what baton reads of the preToolUse hook's input: the copilot's session and +// folder, and the tool that the copilot calls, with its arguments (facts 4 and 36). type guardInput struct { - Dir string `json:"cwd"` - Tool string `json:"toolName"` - Args jsontext.Value `json:"toolArgs"` + Session string `json:"sessionId"` + Dir string `json:"cwd"` + Tool string `json:"toolName"` + Args jsontext.Value `json:"toolArgs"` } -// guardOutput is the preToolUse hook's answer that denies the tool, with the reason. +// guardOutput is the preToolUse hook's answer, deny or ask, with the reason. Its zero value says +// nothing, which leaves the tool to copilot's own permissions. type guardOutput struct { Decision string `json:"permissionDecision"` Reason string `json:"permissionDecisionReason"` } +// deny returns the answer that denies the tool for the reason, or none for no reason. +func deny(reason string) guardOutput { + if reason == "" { + return guardOutput{} + } + + return guardOutput{"deny", reason} +} + +// paneRules are the guard's rules in one kind of pane: its answer to the commands of a bash +// script that the guard watches, and its reason to deny a file tool's write of the path, or "". +type paneRules struct { + script func(commands []guard.Command) (guardOutput, error) + write func(path string) (string, error) +} + // hookGuard is the plugin's preToolUse hook, which runs before each tool that a pane's copilot -// calls. In a role's pane, it denies a bash script that the guard can't read, or that runs a -// command that the guard watches other than baton's commands for a role, and a create or edit of -// a file in baton's folder or in baton's cache folder, which holds the plugin. It says nothing in -// the orchestrator's pane yet. Copilot denies the tool when the hook fails, so baton then reports -// why and exits with 1 (decisions 52 and 53). +// calls, and answers by the pane's rules: a role's (decisions 52 and 53) or the orchestrator's +// (decision 54). Copilot denies the tool when the hook fails, so baton then reports why and exits +// with 1. func hookGuard(ctx context.Context, env Env, args []string) error { if err := parse(newFlags(env, "hook guard"), args); err != nil { return err @@ -67,22 +84,30 @@ func hookGuard(ctx context.Context, env Env, args []string) error { } role, _, err := fromPane(env.Getenv) - if err != nil || role == flow.RoleOrchestrator { + if err != nil { return err } - reason, err := guardReason(ctx, cmp.Or(input.Dir, env.Dir), input) - if err != nil || reason == "" { + dir := cmp.Or(input.Dir, env.Dir) + + rules := roleRules(ctx, dir) + if role == flow.RoleOrchestrator { + rules = orchestratorRules(ctx, env, dir, input.Session) + } + + answer, err := guardAnswer(dir, input, rules) + if err != nil || answer == (guardOutput{}) { return err } - return json.MarshalEncode(jsontext.NewEncoder(env.Stdout), guardOutput{"deny", reason}) + return json.MarshalEncode(jsontext.NewEncoder(env.Stdout), answer) } -// guardReason returns the reason to deny the tool in a role's pane, or "" to let it run: bash by -// its script, and create and edit by the file that they write. The role's other tools read, or -// look after its shells (fact 36). A relative path starts in the folder. -func guardReason(ctx context.Context, dir string, input guardInput) (string, error) { +// guardAnswer returns the guard's answer to the tool by the pane's rules: to bash by the commands +// of its script that the guard watches, and to create and edit by the file that they write. The +// pane's other tools read, or look after its shells (fact 36). It denies a script that it can't +// read in every pane. A relative path starts in the folder. +func guardAnswer(dir string, input guardInput, rules paneRules) (guardOutput, error) { switch input.Tool { case "bash": var args struct { @@ -90,47 +115,88 @@ func guardReason(ctx context.Context, dir string, input guardInput) (string, err } if err := json.Unmarshal(input.Args, &args); err != nil { - return "", fmt.Errorf("read the arguments of bash: %w", err) + return guardOutput{}, fmt.Errorf("read the arguments of bash: %w", err) + } + + commands, err := guard.Commands(args.Command) + if err != nil { + return deny(fmt.Sprintf("%sThe guard can't read the script (%v): write it in "+ + "plain bash.", stopped, err)), nil } - return scriptReason(args.Command), nil + return rules.script(commands) case "create", "edit": var args struct { Path string `json:"path"` } if err := json.Unmarshal(input.Args, &args); err != nil { - return "", fmt.Errorf("read the arguments of %s: %w", input.Tool, err) + return guardOutput{}, fmt.Errorf("read the arguments of %s: %w", input.Tool, err) } - return pathReason(ctx, dir, args.Path) + reason, err := rules.write(absolute(dir, args.Path)) + + return deny(reason), err default: - return "", nil + return guardOutput{}, nil } } -// scriptReason returns the reason to deny a bash script in a role's pane, or "" to let it run: a -// script that the guard can't read, or its first command that the guard watches other than -// baton's commands for a role. -func scriptReason(script string) string { - commands, err := guard.Commands(script) - if err != nil { - return fmt.Sprintf("%sThe guard can't read the script (%v): write it in plain bash.", - stopped, err) - } +// firstAnswer returns the judge's answer to the first command that it denies, or else to the +// first that it asks the user about, or none. +func firstAnswer(commands []guard.Command, judge func(guard.Command) guardOutput) guardOutput { + var asked guardOutput for _, command := range commands { - if !slices.Contains(roleCommands, command.Name) { - return fmt.Sprintf("%s`%s` %s", stopped, command.Name, why[command.Kind]) + answer := judge(command) + if answer.Decision == "deny" { + return answer } + + asked = cmp.Or(asked, answer) } - return "" + return asked } -// pathReason returns the reason to deny a file tool's write in a role's pane, or "" to let it run: -// a file in baton's folder, which only baton writes (decision 48), or in baton's cache folder, -// whose plugin runs this guard (decision 53). +// absolute returns the path, made absolute from the folder if it is relative. An empty folder is +// the process's own working folder, as in main. +func absolute(dir, path string) string { + if !filepath.IsAbs(path) { + path = filepath.Join(dir, path) + } + + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + + return path +} + +// roleRules are the guard's rules in a role's pane, which nobody watches: it denies each command +// that it watches but baton's commands for a role, and a write in baton's folder or its cache +// folder (decisions 52 and 53). +func roleRules(ctx context.Context, dir string) paneRules { + return paneRules{ + script: func(commands []guard.Command) (guardOutput, error) { + return firstAnswer(commands, roleCommand), nil + }, + write: func(path string) (string, error) { return pathReason(ctx, dir, path) }, + } +} + +// roleCommand returns the guard's answer to a command that it watches in a role's pane. +func roleCommand(command guard.Command) guardOutput { + if slices.Contains(roleCommands, command.Name) { + return guardOutput{} + } + + return deny(fmt.Sprintf("%s`%s` %s", stopped, command.Name, why[command.Kind])) +} + +// pathReason returns the reason to deny a file tool's write of the absolute path in a role's pane, +// or "" to let it run: a file in baton's folder, which only baton writes (decision 48), or in +// baton's cache folder, whose plugin runs this guard (decision 53). func pathReason(ctx context.Context, dir, path string) (string, error) { cache, err := batonCache() if err != nil { @@ -142,15 +208,6 @@ func pathReason(ctx context.Context, dir, path string) (string, error) { return "", err } - if !filepath.IsAbs(path) { - path = filepath.Join(dir, path) - } - - // An empty dir is the process's own working folder, as in main. - if abs, err := filepath.Abs(path); err == nil { - path = abs - } - switch { case within(path, store.Dir): return fmt.Sprintf("%s%s is in baton's folder %s, which only baton writes: write your "+ diff --git a/internal/cli/guard_orchestrator.go b/internal/cli/guard_orchestrator.go new file mode 100644 index 0000000..ac8d985 --- /dev/null +++ b/internal/cli/guard_orchestrator.go @@ -0,0 +1,122 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + + "github.com/codedogapp/baton/internal/guard" +) + +// featureStart is the guard's name for baton feature start, which the user approves while plan +// approval is on (decision 54). +const featureStart = "baton feature start" + +// orchestratorCommands are baton's commands that the orchestrator runs unasked, by the names +// that the guard gives them. +var orchestratorCommands = slices.Concat( + []string{"baton answer", "baton handover", "baton status", "baton wait"}, helpCommands) + +// orchestratorWhy says why the guard denies the orchestrator a command of each kind that it +// denies, and what the orchestrator does instead. +var orchestratorWhy = map[guard.Kind]string{ + guard.Control: "drives the user's tmux panes or the Mac's apps, which the orchestrator " + + "never does: baton runs the panes, and `baton status` shows the features.", + guard.Agent: "starts another agent, which the orchestrator never does: start a feature, " + + "and its team does the work.", + guard.Baton: "isn't the orchestrator's command: it runs `baton feature start`, " + + "`baton wait`, `baton answer`, `baton handover` and `baton status`.", + guard.Unknown: why[guard.Unknown], +} + +// guardAsks starts each question that baton hook guard asks the user in the orchestrator's pane, +// which copilot shows the user (decision 54). +const guardAsks = "baton's guard asks you: " + +// orchestratorRules are the guard's rules in the orchestrator's pane, which the user watches +// (decision 54). It runs baton's commands for the orchestrator, asks the user before each command +// that acts outside the Mac and, while plan approval is on, before each baton feature start, and +// denies the other commands that it watches. It denies a write outside the copilot's session +// folder, which the input's session names. +func orchestratorRules(ctx context.Context, env Env, dir, session string) paneRules { + return paneRules{ + script: func(commands []guard.Command) (guardOutput, error) { + approval, err := planApproval(ctx, dir, commands) + if err != nil { + return guardOutput{}, err + } + + return firstAnswer(commands, func(command guard.Command) guardOutput { + return orchestratorCommand(command, approval) + }), nil + }, + write: func(path string) (string, error) { + folder, err := sessionFolder(env, session) + if err != nil || within(path, folder) { + return "", err + } + + return fmt.Sprintf("%s%s is outside your session's folder %s: the team writes the "+ + "repo's files, and you write your briefs and notes in its files/ folder.", stopped, + path, folder), nil + }, + } +} + +// planApproval reports whether the user approves each baton feature start, by the settings of +// the folder's repo, which it loads only for a script that starts a feature. +func planApproval(ctx context.Context, dir string, commands []guard.Command) (bool, error) { + if !slices.ContainsFunc(commands, func(command guard.Command) bool { + return command.Name == featureStart + }) { + return false, nil + } + + settings, err := loadSettings(ctx, dir) + + return settings.PlanApprovalOn(), err +} + +// orchestratorCommand returns the guard's answer to a command that it watches in the +// orchestrator's pane, where approval says whether the user approves each feature start. +func orchestratorCommand(command guard.Command, approval bool) guardOutput { + switch { + case slices.Contains(orchestratorCommands, command.Name): + return guardOutput{} + case command.Name == featureStart && approval: + return guardOutput{"ask", guardAsks + "`baton feature start` starts a feature's team on " + + "its brief. Allow it if you approved the plan; planApproval in baton's config file " + + "turns this question off."} + case command.Name == featureStart: + return guardOutput{} + case command.Kind == guard.Outward: + return guardOutput{"ask", fmt.Sprintf("%s`%s` can act outside the Mac. Allow it only "+ + "if you want it to.", guardAsks, command.Name)} + } + + return deny(fmt.Sprintf("%s`%s` %s", stopped, command.Name, orchestratorWhy[command.Kind])) +} + +// sessionFolder returns the copilot session's folder, in which the orchestrator writes its briefs +// and notes: session-state/ in copilot's home folder, which COPILOT_HOME names in the +// pane, or else .copilot in the user's home folder. +func sessionFolder(env Env, session string) (string, error) { + // filepath.Base gives "." for "", so an empty ID fails too. + if session == "." || session == ".." || session != filepath.Base(session) { + return "", fmt.Errorf("read the hook's input: sessionId %q: want a session's ID", session) + } + + home := env.Getenv("COPILOT_HOME") + if home == "" { + user, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("find the home folder: %w", err) + } + + home = filepath.Join(user, ".copilot") + } + + return filepath.Join(home, "session-state", session), nil +} diff --git a/internal/cli/guard_orchestrator_test.go b/internal/cli/guard_orchestrator_test.go new file mode 100644 index 0000000..afde8b4 --- /dev/null +++ b/internal/cli/guard_orchestrator_test.go @@ -0,0 +1,143 @@ +package cli_test + +import ( + "testing" +) + +// The questions and reasons of baton hook guard in the orchestrator's pane (decision 54). +const ( + guardAsks = "baton's guard asks you: " + toApprove = guardAsks + "`baton feature start` starts a feature's team on its brief. Allow " + + "it if you approved the plan; planApproval in baton's config file turns this question off." + orchestratorControl = "drives the user's tmux panes or the Mac's apps, which the " + + "orchestrator never does: baton runs the panes, and `baton status` shows the features." + orchestratorAgent = "starts another agent, which the orchestrator never does: start a " + + "feature, and its team does the work." + orchestratorBaton = "isn't the orchestrator's command: it runs `baton feature start`, " + + "`baton wait`, `baton answer`, `baton handover` and `baton status`." +) + +// asks returns the guard's question to the user about the orchestrator's outward command, which +// the guard names so. +func asks(name string) verdict { + return verdict{"ask", guardAsks + "`" + name + "` can act outside the Mac. Allow it only if " + + "you want it to."} +} + +// forbids returns the guard's answer that denies the orchestrator's script for the command, which +// the guard names so. +func forbids(name, why string) verdict { return verdict{"deny", denied(name, why)} } + +// TestGuardHookAsksTheUserAboutTheOrchestratorsCommands asks the user before the orchestrator's +// first command that can act outside the Mac, denies its first command that the guard watches, +// but baton's commands for the orchestrator and those that ask, and a script that the guard can't +// read, and lets the others run (decision 54). A denial wins over a question. +func TestGuardHookAsksTheUserAboutTheOrchestratorsCommands(t *testing.T) { + cases := map[string]verdict{ + "git status && git log -1": {}, + "curl -fsS https://example.com/docs": {}, + "": {}, + "git push origin HEAD": asks("git push"), + "gh pr create --fill": asks("gh pr create"), + "curl -d x https://h/": asks("curl"), + "git status; git push && gh pr merge 7": asks("git push"), + "tmux send-keys -t 0 y": forbids("tmux", orchestratorControl), + "osascript -e 'beep'": forbids("osascript", orchestratorControl), + "claude -p 'Review it'": forbids("claude", orchestratorAgent), + `"$GIT" push`: forbids("… push", toUnknown), + "baton ask 'Which one?'": forbids("baton ask", orchestratorBaton), + "baton hook guard": forbids("baton hook guard", orchestratorBaton), + "baton pane": forbids("baton pane", orchestratorBaton), + "baton feature": forbids("baton feature", orchestratorBaton), + "baton": forbids("baton", orchestratorBaton), + "baton wait": {}, + "baton wait -h": {}, + "baton answer demo-1 'Keep it'": {}, + "baton handover demo-1 --to lead": {}, + "baton status": {}, + "baton help feature": {}, + "baton -h": {}, + "baton -help": {}, + "baton --help": {}, + "git push && tmux ls": forbids("tmux", orchestratorControl), + "tmux ls; gh pr create && claude -p 'x'": forbids("tmux", orchestratorControl), + "echo 'open": {"deny", unreadable}, + } + + dir := t.TempDir() + + for script, want := range cases { + t.Run(script, func(t *testing.T) { + args := map[string]any{"command": script, "description": "Run it", "mode": "sync"} + input := guardInput(t, dir, "bash", args) + wantAnswer(t, guardHook(t, dir, orchestratorPane(), input), want) + }) + } +} + +// TestGuardHookAsksTheUserBeforeAFeatureStarts asks the user before the orchestrator's baton +// feature start while plan approval is on, as it is by default, and lets it run when the config +// turns it off; the guard's other answers stand either way (decision 54). +func TestGuardHookAsksTheUserBeforeAFeatureStarts(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + mainDir := repoNamed(t, "main") + + const start = "baton feature start demo-1 --brief /tmp/brief.md" + + cases := []struct { + name string + config string // The config file's text, or "" for none. + script string + want verdict + }{ + {"by default", "", start, verdict{"ask", toApprove}}, + {"with plan approval on", `{"planApproval": true}`, start, verdict{"ask", toApprove}}, + {"with plan approval off", `{"planApproval": false}`, start, verdict{}}, + {"with help", "", "baton feature start -h", verdict{"ask", toApprove}}, + {"before an outward command", "", start + " && git push", verdict{"ask", toApprove}}, + {"after an outward command", "", "git push; " + start, asks("git push")}, + { + "with plan approval off, and an outward command", `{"planApproval": false}`, + start + " && git push", asks("git push"), + }, + { + "before a command that the guard denies", "", start + " && tmux ls", + forbids("tmux", orchestratorControl), + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + if test.config != "" { + writeConfig(t, test.config) + } + + input := guardInput(t, mainDir, "bash", map[string]any{"command": test.script}) + wantAnswer(t, guardHook(t, mainDir, orchestratorPane(), input), test.want) + }) + } +} + +// TestGuardHookLetsTheOrchestratorsOtherToolsRun lets the orchestrator's tools other than bash, +// create and edit run, whatever their arguments: they read, look after its shells, or talk to +// the user. +func TestGuardHookLetsTheOrchestratorsOtherToolsRun(t *testing.T) { + mainDir := repoNamed(t, "main") + + cases := map[string]any{ + "view": map[string]any{"path": mainDir + "/.git/config"}, + "grep": map[string]any{"pattern": "push", "path": mainDir}, + "web_fetch": map[string]any{"url": "https://example.com/?q=push"}, + "read_bash": map[string]any{"shellId": "7", "delay": 5}, + "ask_user": map[string]any{"message": "git push?"}, + "nudge-report": map[string]any{"note": "Started demo-1."}, + } + + for tool, args := range cases { + t.Run(tool, func(t *testing.T) { + input := guardInput(t, mainDir, tool, args) + wantGuard(t, guardHook(t, mainDir, orchestratorPane(), input), "") + }) + } +} diff --git a/internal/cli/guard_session_test.go b/internal/cli/guard_session_test.go new file mode 100644 index 0000000..7cd6c66 --- /dev/null +++ b/internal/cli/guard_session_test.go @@ -0,0 +1,165 @@ +package cli_test + +import ( + "encoding/json/v2" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestGuardHookKeepsTheOrchestratorsWritesInItsSessionsFolder lets the orchestrator's create and +// edit write in its copilot session's folder, in the copilot home folder that COPILOT_HOME names, +// however the path names it, and denies them anywhere else: in the repo, its worktrees, baton's +// folder, or another session's folder (decision 54). +func TestGuardHookKeepsTheOrchestratorsWritesInItsSessionsFolder(t *testing.T) { + mainDir, linked := repos(t) + store := started(t, mainDir, fresh()) + + home := t.TempDir() + folder := sessionIn(t, home) + + link := filepath.Join(t.TempDir(), "session") + if err := os.Symlink(folder, link); err != nil { + t.Fatal(err) + } + + other := filepath.Join(home, "session-state", "9f1e7a3c") + cases := []struct { + name string + path string // The path in the tool's arguments. + want string // The path that the reason names, or "" to let the tool run. + }{ + {"the session's plan", folder + "/plan.md", ""}, + {"a brief", folder + "/files/brief.md", ""}, + {"a note in a new folder", folder + "/files/demo-1/note.md", ""}, + {"a symlink", link + "/files/brief.md", ""}, + {"the repo", mainDir + "/main.go", mainDir + "/main.go"}, + {"a relative path", "main.go", mainDir + "/main.go"}, + {"a worktree", linked + "/main.go", linked + "/main.go"}, + {"baton's folder", store.Dir + "/x.md", store.Dir + "/x.md"}, + {"another session's folder", other + "/files/x.md", other + "/files/x.md"}, + {"copilot's own file", home + "/config.json", home + "/config.json"}, + {"a path with ..", folder + "/../9f1e7a3c/x.md", other + "/x.md"}, + {"a folder beside it", folder + "-old/x.md", folder + "-old/x.md"}, + } + + pane := map[string]string{"BATON_ROLE": "orchestrator", "COPILOT_HOME": home} + + for _, test := range cases { + for _, tool := range []string{"create", "edit"} { + t.Run(tool+" "+test.name, func(t *testing.T) { + want := "" + if test.want != "" { + want = outsideSession(test.want, folder) + } + + input := guardInput(t, mainDir, tool, map[string]any{"path": test.path}) + wantGuard(t, guardHook(t, mainDir, pane, input), want) + }) + } + } +} + +// TestGuardHookFindsTheSessionsFolderInTheHomeFolder finds the session's folder in the user's +// home folder's .copilot when the pane has no COPILOT_HOME, as copilot does, and holds nothing in +// a session folder that isn't there. +func TestGuardHookFindsTheSessionsFolderInTheHomeFolder(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + mainDir := repoNamed(t, "main") + folder := sessionIn(t, filepath.Join(home, ".copilot")) + brief := guardInput(t, mainDir, "create", map[string]any{"path": folder + "/files/brief.md"}) + inRepo := guardInput(t, mainDir, "create", map[string]any{"path": mainDir + "/x.md"}) + + wantGuard(t, guardHook(t, mainDir, orchestratorPane(), brief), "") + wantGuard(t, guardHook(t, mainDir, orchestratorPane(), inRepo), + outsideSession(mainDir+"/x.md", folder)) + + elsewhere := map[string]string{"BATON_ROLE": "orchestrator", "COPILOT_HOME": t.TempDir()} + missing := filepath.Join(elsewhere["COPILOT_HOME"], "session-state", session) + wantGuard(t, guardHook(t, mainDir, elsewhere, brief), + outsideSession(folder+"/files/brief.md", missing)) +} + +// TestGuardHookFailsWithoutTheSessionsFolder fails, and so denies the orchestrator's file tool, +// when it can't find the session's folder: the input names no session, or one that isn't a +// folder's name, or the user has no home folder. +func TestGuardHookFailsWithoutTheSessionsFolder(t *testing.T) { + mainDir := repoNamed(t, "main") + pane := map[string]string{"BATON_ROLE": "orchestrator", "COPILOT_HOME": t.TempDir()} + + for _, name := range []string{"", ".", "..", "a/b", "/tmp"} { + t.Run(name, func(t *testing.T) { + input, err := json.Marshal(map[string]any{ + "sessionId": name, "cwd": mainDir, "toolName": "edit", "toolArgs": map[string]any{ + "path": mainDir + "/x.md", + }, + }) + if err != nil { + t.Fatal(err) + } + + wantFailure(t, guardHook(t, mainDir, pane, string(input)), + `read the hook's input: sessionId "`+name+`": want a session's ID`) + }) + } + + t.Setenv("HOME", "") + + input := guardInput(t, mainDir, "create", map[string]any{"path": mainDir + "/x.md"}) + wantFailure(t, guardHook(t, mainDir, orchestratorPane(), input), "find the home folder: ") +} + +// TestGuardHookReadsTheSettingsOnlyForAFeatureStart reads the repo's settings only for a script +// that starts a feature, and fails, and so denies it, when it can't: for the config file's error, +// or a folder outside a git repo. +func TestGuardHookReadsTheSettingsOnlyForAFeatureStart(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + mainDir := repoNamed(t, "main") + outside := t.TempDir() + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(outside)) + writeConfig(t, `{"planApproval": "no"}`) + + status := guardInput(t, mainDir, "bash", map[string]any{"command": "git status"}) + wantGuard(t, guardHook(t, mainDir, orchestratorPane(), status), "") + + start := map[string]any{"command": "baton feature start demo-1 --brief /tmp/brief.md"} + wantFailure(t, guardHook(t, mainDir, orchestratorPane(), guardInput(t, mainDir, "bash", start)), + "config file ") + wantFailure(t, guardHook(t, outside, orchestratorPane(), guardInput(t, outside, "bash", start)), + "find the origin remote: ") +} + +// sessionIn makes the folder of the hook input's session, with its files folder, in the copilot +// home folder, and returns it. +func sessionIn(t *testing.T, home string) string { + t.Helper() + + folder := filepath.Join(home, "session-state", session) + if err := os.MkdirAll(filepath.Join(folder, "files"), 0o700); err != nil { + t.Fatal(err) + } + + return folder +} + +// outsideSession returns the reason that denies the orchestrator's write of the path, outside +// the session's folder. +func outsideSession(path, folder string) string { + return guardStopped + path + " is outside your session's folder " + folder + ": the team " + + "writes the repo's files, and you write your briefs and notes in its files/ folder." +} + +// wantFailure checks that baton hook guard failed with the reason, and so said nothing on stdout, +// which has copilot deny the tool. +func wantFailure(t *testing.T, got result, reason string) { + t.Helper() + + want := "baton hook guard: " + reason + if got.code != 1 || got.stdout != "" || !strings.HasPrefix(got.stderr, want) { + t.Errorf("got %+v; want exit code 1 and %q", got, want) + } +} diff --git a/internal/cli/guard_test.go b/internal/cli/guard_test.go index fb93ce3..9c0eef7 100644 --- a/internal/cli/guard_test.go +++ b/internal/cli/guard_test.go @@ -9,6 +9,9 @@ import ( "github.com/codedogapp/baton/internal/flow" ) +// session is the ID of the copilot session in the hook's input, which names its folder. +const session = "0b5d2c4e" + // The reasons that baton hook guard denies a role's tool with (decision 52). const ( guardStopped = "baton's guard stopped this call, and none of it ran. " @@ -20,6 +23,9 @@ const ( "`baton ask` and `baton status`." toUnknown = "runs a program that only the run decides, which the guard can't check: name " + "the program in the script itself." + // unreadable denies the script echo 'open, which the guard can't read, in every pane. + unreadable = guardStopped + "The guard can't read the script (1:6: reached EOF without " + + "closing quote `'`): write it in plain bash." ) // denied returns the reason that denies a role's script for the command, which the guard names so. @@ -41,8 +47,8 @@ func TestGuardHookDeniesARoleWhatTheGuardWatches(t *testing.T) { "claude -p 'Review it'": denied("claude", toAgent), `"$GIT" push`: denied("… push", toUnknown), "baton answer demo-1 y": denied("baton answer", toBaton), - "baton feature start x": denied("baton feature", toBaton), - "baton hook stop": denied("baton hook", toBaton), + "baton feature start x": denied("baton feature start", toBaton), + "baton hook stop": denied("baton hook stop", toBaton), "baton pane": denied("baton pane", toBaton), "baton": denied("baton", toBaton), `baton "$verb"`: denied("baton …", toBaton), @@ -56,8 +62,7 @@ func TestGuardHookDeniesARoleWhatTheGuardWatches(t *testing.T) { "baton -help": "", "baton --help": "", "git status && baton wait && git push && tmux ls": denied("git push", toOutward), - "echo 'open": guardStopped + "The guard can't read the script (1:6: reached EOF without " + - "closing quote `'`): write it in plain bash.", + "echo 'open": unreadable, } dir := t.TempDir() @@ -97,25 +102,6 @@ func TestGuardHookLetsARolesOtherToolsRun(t *testing.T) { } } -// TestGuardHookSaysNothingInTheOrchestratorsPane lets every tool of the orchestrator's pane run -// on copilot's own terms, since the orchestrator's guard comes later (decision 52). -func TestGuardHookSaysNothingInTheOrchestratorsPane(t *testing.T) { - mainDir, _ := repos(t) - store := started(t, mainDir, fresh()) - - cases := map[string]map[string]any{ - "bash": {"command": "git push && tmux ls"}, - "create": {"path": store.Dir + "/brief.md", "file_text": "Build it.\n"}, - } - - for tool, args := range cases { - t.Run(tool, func(t *testing.T) { - input := guardInput(t, mainDir, tool, args) - wantGuard(t, guardHook(t, mainDir, orchestratorPane(), input), "") - }) - } -} - // guardHook runs baton hook guard in the pane, from the folder, with the hook's input. func guardHook(t *testing.T, dir string, env map[string]string, input string) result { t.Helper() @@ -132,13 +118,13 @@ func guardEnv(dir string, env map[string]string, input string) cli.Env { return hook } -// guardInput returns the input that copilot gives its preToolUse hook when a pane's copilot calls -// the tool with the arguments in the folder, if any. +// guardInput returns the input that copilot gives its preToolUse hook when a pane's copilot, in +// the session, calls the tool with the arguments in the folder, if any. func guardInput(t *testing.T, dir, tool string, args any) string { t.Helper() input := map[string]any{ - "sessionId": "0b5d2c4e", "timestamp": 1767225600000, "toolName": tool, "toolArgs": args, + "sessionId": session, "timestamp": 1767225600000, "toolName": tool, "toolArgs": args, } if dir != "" { input["cwd"] = dir @@ -153,11 +139,21 @@ func guardInput(t *testing.T, dir, tool string, args any) string { } // wantGuard checks that baton hook guard let the tool run, without a word, if reason is "", and -// otherwise denied it with the reason: a JSON object, on a line of its own. +// otherwise denied it with the reason. func wantGuard(t *testing.T, got result, reason string) { t.Helper() + wantAnswer(t, got, verdict{"deny", reason}) +} + +// verdict is baton hook guard's answer to a tool: deny or ask, with the reason, or none. +type verdict struct{ decision, reason string } + +// wantAnswer checks that baton hook guard let the tool run, without a word, if the reason is "", +// and otherwise gave its answer with the reason: a JSON object, on a line of its own. +func wantAnswer(t *testing.T, got result, want verdict) { + t.Helper() - if reason == "" { + if want.reason == "" { if got != (result{}) { t.Errorf("got %+v; want exit code 0 and no output", got) } @@ -175,7 +171,8 @@ func wantGuard(t *testing.T, got result, reason string) { t.Fatalf("got %+v; want exit code 0 and a JSON object", got) } - if answer.Decision != "deny" || answer.Reason != reason { - t.Errorf("got %+v; want the decision deny, with the reason %q", answer, reason) + if answer.Decision != want.decision || answer.Reason != want.reason { + t.Errorf("got %+v; want the decision %s, with the reason %q", answer, want.decision, + want.reason) } } diff --git a/internal/cli/pane.go b/internal/cli/pane.go index 303b010..94aa3ae 100644 --- a/internal/cli/pane.go +++ b/internal/cli/pane.go @@ -194,14 +194,14 @@ func command(role flow.Role, dir string, own config.Role, work string) []string } // orchestrating returns the orchestrator's copilot command line: its agent in the plugin, its -// settings, and the tools, with ask_user and its extra tools, the user's own, such as nudge's, -// since the user talks to it. It keeps copilot's own prompts for the tools, and has no first -// prompt, since the user speaks first (decisions 45 and 49). +// settings, every permission, since baton's guard asks the user what needs asking, and the tools, +// with ask_user and its extra tools, the user's own, such as nudge's, since the user talks to it. +// It has no first prompt, since the user speaks first (decisions 45, 49 and 54). func orchestrating(dir string, own config.Role) []string { return slices.Concat([]string{ "copilot", "--agent", plugin.Agent(flow.RoleOrchestrator), "--plugin-dir", dir, - }, settingFlags(own), []string{"--available-tools"}, tools, []string{"ask_user"}, - own.ExtraTools) + }, settingFlags(own), []string{"--allow-all", "--available-tools"}, tools, + []string{"ask_user"}, own.ExtraTools) } // settingFlags returns copilot's flags for the role's settings: its model, and its reasoning diff --git a/internal/cli/pane_test.go b/internal/cli/pane_test.go index 4784a05..3ed5a1f 100644 --- a/internal/cli/pane_test.go +++ b/internal/cli/pane_test.go @@ -129,9 +129,9 @@ func TestPaneBecomesTheOrchestratorsCopilot(t *testing.T) { path: copilot, args: []string{ "copilot", "--agent", "baton:orchestrator", "--plugin-dir", pluginIn(t, cache), - "--model", "claude-opus-5.5", "--available-tools", "bash", "read_bash", "stop_bash", - "list_bash", "view", "create", "edit", "grep", "glob", "web_fetch", "ask_user", - "nudge-ask", "nudge-report", + "--model", "claude-opus-5.5", "--allow-all", "--available-tools", "bash", "read_bash", + "stop_bash", "list_bash", "view", "create", "edit", "grep", "glob", "web_fetch", + "ask_user", "nudge-ask", "nudge-report", }, env: append(withoutVars("BATON_BIN"), "BATON_BIN="+self), }) diff --git a/internal/cli/settings_test.go b/internal/cli/settings_test.go index c9f0752..0e41f40 100644 --- a/internal/cli/settings_test.go +++ b/internal/cli/settings_test.go @@ -74,9 +74,9 @@ func TestPaneGivesTheOrchestratorItsSettings(t *testing.T) { path: copilot, args: []string{ "copilot", "--agent", "baton:orchestrator", "--plugin-dir", pluginIn(t, cache), - "--model", "claude-sonnet-5.5", "--max-ai-credits", "30", "--available-tools", - "bash", "read_bash", "stop_bash", "list_bash", "view", "create", "edit", "grep", - "glob", "web_fetch", "ask_user", + "--model", "claude-sonnet-5.5", "--max-ai-credits", "30", "--allow-all", + "--available-tools", "bash", "read_bash", "stop_bash", "list_bash", "view", "create", + "edit", "grep", "glob", "web_fetch", "ask_user", }, env: append(withoutVars("BATON_BIN"), "BATON_BIN="+self), }) diff --git a/internal/config/approval_test.go b/internal/config/approval_test.go new file mode 100644 index 0000000..fa9a5fe --- /dev/null +++ b/internal/config/approval_test.go @@ -0,0 +1,36 @@ +package config_test + +import ( + "testing" + + "github.com/codedogapp/baton/internal/config" +) + +func TestLoadTurnsPlanApprovalOffAndOn(t *testing.T) { + for content, want := range map[string]bool{ + `{}`: true, + `{"planApproval": true}`: true, + `{"planApproval": false}`: false, + `{"planApproval": null}`: true, + `{"planApproval": false, "repos": {"` + repo + `": {"planApproval": true}}}`: true, + `{"planApproval": false, "repos": {"` + repo + `": {}}}`: false, + `{"planApproval": false, "repos": {"` + repo + `": {"planApproval": null}}}`: false, + `{"repos": {"` + repo + `": {"planApproval": false}}}`: false, + `{"repos": {"github.com/codedogapp/tap": {"planApproval": false}}}`: true, + } { + t.Run(content, func(t *testing.T) { + got := load(t, content, remote) + if got.PlanApproval == nil || *got.PlanApproval != want || + got.PlanApprovalOn() != want { + t.Errorf("plan approval is %v (%v), want %v", got.PlanApproval, + got.PlanApprovalOn(), want) + } + }) + } +} + +func TestPlanApprovalIsOnWhereSettingsGiveNone(t *testing.T) { + if !(config.Settings{}).PlanApprovalOn() { + t.Error("plan approval is off in settings that give none, want on") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5f2fc80..d81be43 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,7 +21,7 @@ const ( file = "config.json" ) -// baton's own settings, where the config file gives none (decisions 7, 13 and 41). +// baton's own settings, where the config file gives none (decisions 7, 8, 13 and 41). const ( model = "claude-opus-5.5" roundCap = 3 @@ -38,6 +38,14 @@ type Settings struct { RoundCap int `json:"roundCap"` // BranchPrefix comes before the slug in the name of a feature's branch (decision 41). BranchPrefix string `json:"branchPrefix"` + // PlanApproval makes the user approve each feature start in the orchestrator's pane (decision + // 8). A nil one keeps the one below it, so that false can turn it off. + PlanApproval *bool `json:"planApproval"` +} + +// PlanApprovalOn reports whether the user approves each feature start in the orchestrator's pane. +func (s Settings) PlanApprovalOn() bool { + return s.PlanApproval == nil || *s.PlanApproval } // Role is a role's settings for its copilot, each one a flag of copilot's (fact 31). @@ -53,8 +61,8 @@ type Role struct { ExtraTools []string `json:"extraTools"` } -// defaults returns baton's own settings: every role on the same model, and the orchestrator with -// the user's nudge tools (decision 45). +// defaults returns baton's own settings: every role on the same model, the orchestrator with the +// user's nudge tools (decision 45), and plan approval on. func defaults() Settings { roles := map[flow.Role]Role{flow.RoleOrchestrator: { Model: model, ExtraTools: []string{"nudge-ask", "nudge-report"}, @@ -66,7 +74,9 @@ func defaults() Settings { roles[role] = Role{Model: model} } - return Settings{Roles: roles, RoundCap: roundCap, BranchPrefix: branchPrefix} + return Settings{ + Roles: roles, RoundCap: roundCap, BranchPrefix: branchPrefix, PlanApproval: new(true), + } } // Load reads the config file in the user's config folder, and returns the settings for the repo @@ -118,6 +128,7 @@ func (s Settings) with(layer Settings) Settings { Roles: roles, RoundCap: cmp.Or(layer.RoundCap, s.RoundCap), BranchPrefix: cmp.Or(layer.BranchPrefix, s.BranchPrefix), + PlanApproval: cmp.Or(layer.PlanApproval, s.PlanApproval), } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1caac10..95395fa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -171,6 +171,7 @@ func own() config.Settings { }, RoundCap: 3, BranchPrefix: "feature/", + PlanApproval: new(true), } } diff --git a/internal/config/refusals_test.go b/internal/config/refusals_test.go index 18f8ad9..5349d4e 100644 --- a/internal/config/refusals_test.go +++ b/internal/config/refusals_test.go @@ -56,12 +56,13 @@ func TestLoadRefusesSettingsThatBatonCantUse(t *testing.T) { func TestLoadRefusesAFileThatIsntSettings(t *testing.T) { for content, want := range map[string]string{ - ``: "unexpected EOF", - `{`: "unexpected EOF", - `[]`: "unmarshal JSON array", - `{"roundCap": "3"}`: `within "/roundCap"`, - `{"rounds": 2}`: `unknown object member name "rounds"`, - `{"RoundCap": 2}`: `unknown object member name "RoundCap"`, + ``: "unexpected EOF", + `{`: "unexpected EOF", + `[]`: "unmarshal JSON array", + `{"roundCap": "3"}`: `within "/roundCap"`, + `{"planApproval": "no"}`: `within "/planApproval"`, + `{"rounds": 2}`: `unknown object member name "rounds"`, + `{"RoundCap": 2}`: `unknown object member name "RoundCap"`, `{"roles": {"lead": {"effort": "high"}}}`: `unknown object member name "effort" within "/roles/lead"`, `{"repos": {"github.com/a/a": {"repos": {}}}}`: `unknown object member name "repos" ` + `within "/repos/github.com~1a~1a"`, diff --git a/internal/guard/guard.go b/internal/guard/guard.go index a49e0af..6e5efcb 100644 --- a/internal/guard/guard.go +++ b/internal/guard/guard.go @@ -4,6 +4,7 @@ package guard import ( + "slices" "strings" "github.com/codedogapp/baton/internal/bash" @@ -42,6 +43,10 @@ var always = map[string]Kind{ "gemini": Agent, "opencode": Agent, } +// batonGroups are the first words of baton's commands of two words, such as feature start, which +// the guard names by both words. +var batonGroups = []string{"feature", "hook"} + // Commands returns the watched commands that the script runs, in the order of bash.Calls, or an // error if the script doesn't parse. func Commands(script string) ([]Command, error) { @@ -79,7 +84,7 @@ func classify(program string, args []string) (Command, bool) { case program == "gh": return gh(args) case program == "baton": - return Command{Baton, name("baton", args[:min(1, len(args))]...)}, true + return Command{Baton, name("baton", args[:batonWords(args)]...)}, true case strings.Contains(program, bash.Dynamic): return Command{Unknown, name(program, args[:min(1, len(args))]...)}, true default: @@ -87,6 +92,16 @@ func classify(program string, args []string) (Command, bool) { } } +// batonWords returns how many of baton's arguments name its command: two for a command of two +// words, else one, if there are as many. +func batonWords(args []string) int { + if len(args) > 1 && slices.Contains(batonGroups, args[0]) { + return 2 + } + + return min(1, len(args)) +} + // name returns a command's name: its words, with … for each part that only the run decides. func name(program string, args ...string) string { words := append([]string{program}, args...) diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index fc8b6ce..873576e 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -160,6 +160,11 @@ func TestCommandsNamesBatonsCommand(t *testing.T) { "/opt/homebrew/bin/baton status": {"baton: baton status"}, `baton "$verb" x`: {"baton: baton …"}, "baton --help": {"baton: baton --help"}, + "baton feature start demo-1": {"baton: baton feature start"}, + "baton hook guard": {"baton: baton hook guard"}, + "baton feature": {"baton: baton feature"}, + `baton feature "$verb"`: {"baton: baton feature …"}, + "baton help feature": {"baton: baton help"}, }) } diff --git a/internal/plugin/files/com.github.copilot/agents/orchestrator.agent.md b/internal/plugin/files/com.github.copilot/agents/orchestrator.agent.md index 7f615c6..c2c7d94 100644 --- a/internal/plugin/files/com.github.copilot/agents/orchestrator.agent.md +++ b/internal/plugin/files/com.github.copilot/agents/orchestrator.agent.md @@ -34,4 +34,5 @@ Ask the user with ask_user. When the user says that they're away, ask with nudge - Never edit the repo's files or commit: the team does the work, in the feature's worktree. - Never type into the panes of a feature's session, and never start agents: baton runs the team, and the handovers move the work. - Never push, open a pull request, merge or write to Jira, unless the user tells you to. +- baton's guard asks the user before each command that can act outside the Mac, and before each `baton feature start` while plan approval is on. If the user says no, ask them what they want instead. The guard also stops your file edits outside your session's folder. - `baton status` shows every feature, with its holder, round and status.