Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
28 changes: 15 additions & 13 deletions docs/plan.md

Large diffs are not rendered by default.

161 changes: 109 additions & 52 deletions internal/cli/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -67,70 +84,119 @@ 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 {
Command string `json:"command"`
}

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 {
Expand All @@ -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 "+
Expand Down
122 changes: 122 additions & 0 deletions internal/cli/guard_orchestrator.go
Original file line number Diff line number Diff line change
@@ -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/<session> 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
}
Loading
Loading