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
18 changes: 11 additions & 7 deletions docs/plan.md

Large diffs are not rendered by default.

184 changes: 184 additions & 0 deletions internal/cli/guard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package cli

import (
"cmp"
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"os"
"path/filepath"
"slices"

"github.com/codedogapp/baton/internal/flow"
"github.com/codedogapp/baton/internal/guard"
)

// stopped starts each reason that baton hook guard gives for a tool that it denies, which copilot
// shows the role's agent (decision 52).
const stopped = "baton's guard stopped this call, and none of it ran. "

// why says why the guard denies a role a command of each kind, and what the role can do instead.
var why = map[guard.Kind]string{
guard.Outward: "can act outside the Mac, which only the orchestrator does, with the user's " +
"approval. Keep your work local, and say in your handover note what should go out.",
guard.Control: "drives the user's tmux panes or the Mac's apps, which a role never does.",
guard.Agent: "starts another agent, which a role never does: do the work yourself.",
guard.Baton: "isn't a role's command: a role runs `baton wait`, `baton handover`, " +
"`baton ask` and `baton status`.",
guard.Unknown: "runs a program that only the run decides, which the guard can't check: name " +
"the program in the script itself.",
}

// 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",
}

// 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).
type guardInput struct {
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.
type guardOutput struct {
Decision string `json:"permissionDecision"`
Reason string `json:"permissionDecisionReason"`
}

// 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. 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 (decision 52).
func hookGuard(ctx context.Context, env Env, args []string) error {
if err := parse(newFlags(env, "hook guard"), args); err != nil {
return err
}

var input guardInput
if err := readInput(env, &input); err != nil {
return err
}

role, _, err := fromPane(env.Getenv)
if err != nil || role == flow.RoleOrchestrator {
return err
}

reason, err := guardReason(ctx, cmp.Or(input.Dir, env.Dir), input)
if err != nil || reason == "" {
return err
}

return json.MarshalEncode(jsontext.NewEncoder(env.Stdout), guardOutput{"deny", reason})
}

// 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) {
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 scriptReason(args.Command), nil
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 pathReason(ctx, dir, args.Path)
default:
return "", 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)
}

for _, command := range commands {
if !slices.Contains(roleCommands, command.Name) {
return fmt.Sprintf("%s`%s` %s", stopped, command.Name, why[command.Kind])
}
}

return ""
}

// 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).
func pathReason(ctx context.Context, dir, path string) (string, error) {
store, err := openStore(ctx, dir)
if err != nil {
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
}

if !within(path, store.Dir) {
return "", nil
}

return fmt.Sprintf("%s%s is in baton's folder %s, which only baton writes: write your notes "+
"in your session's files/ folder.", stopped, path, store.Dir), nil
}

// within reports whether the path is the folder or lies in it, by the files that they name, so
// that neither a symlink nor a disk that ignores case hides it. A path that isn't there yet, such
// as a new file's, counts by the part of it that is. A folder that isn't there holds nothing.
func within(path, folder string) bool {
want, err := os.Stat(folder)
if err != nil {
return false
}

for dir := resolve(filepath.Clean(path)); ; dir = filepath.Dir(dir) {
if info, err := os.Stat(dir); err == nil && os.SameFile(info, want) {
return true
}

if dir == filepath.Dir(dir) {
return false
}
}
}

// resolve returns the path with the symlinks resolved in the longest part of it that is there,
// and the rest as it is.
func resolve(path string) string {
resolved, err := filepath.EvalSymlinks(path)
if err == nil || path == filepath.Dir(path) {
return cmp.Or(resolved, path)
}

return filepath.Join(resolve(filepath.Dir(path)), filepath.Base(path))
}
170 changes: 170 additions & 0 deletions internal/cli/guard_files_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package cli_test

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/codedogapp/baton/internal/cli"
"github.com/codedogapp/baton/internal/flow"
)

// TestGuardHookKeepsTheFileToolsOutOfBatonsFolder denies a role's create and edit of a file in
// baton's folder, which only baton writes (decision 48), however the path names it: through a
// symlink, with .., from the copilot's folder, in a new folder, or, on a disk that ignores case,
// in other case. It lets them write anywhere else, even beside baton's folder.
func TestGuardHookKeepsTheFileToolsOutOfBatonsFolder(t *testing.T) {
mainDir, linked := repos(t)
store := started(t, mainDir, fresh())

link := filepath.Join(linked, "notes")
if err := os.Symlink(filepath.Join(store.Dir, "features"), link); err != nil {
t.Fatal(err)
}

// On a disk that ignores case, the path names baton's folder; on another, a folder that isn't
// there.
upper, inOtherCase := filepath.Join(mainDir, ".git", "BATON"), ""
if _, err := os.Stat(upper); err == nil {
inOtherCase = upper + "/x.md"
}

feature := filepath.Join(store.Dir, "features", slug, "feature.json")
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.
}{
{"baton's folder", store.Dir, store.Dir},
{"a file in it", feature, feature},
{"a new note", feature + ".md", feature + ".md"},
{"a file in a new folder", store.Dir + "/a/b/c.md", store.Dir + "/a/b/c.md"},
{"a path with ..", linked + "/../main/.git/baton/x.md", store.Dir + "/x.md"},
{"a relative path", "../main/.git/baton/x.md", store.Dir + "/x.md"},
{"a symlink", link + "/demo-1/feature.json", link + "/demo-1/feature.json"},
{"a new file behind a symlink", link + "/x.md", link + "/x.md"},
{"a path in other case", upper + "/x.md", inOtherCase},
{"the worktree", linked + "/main.go", ""},
{"git's folder", mainDir + "/.git/config", ""},
{"a folder beside baton's", mainDir + "/.git/baton-old/x.md", ""},
{"a session's folder", t.TempDir() + "/files/note.md", ""},
}

for _, test := range cases {
for _, tool := range []string{"create", "edit"} {
t.Run(tool+" "+test.name, func(t *testing.T) {
args := map[string]any{"path": test.path, "old_str": "a", "new_str": "b"}

want := ""
if test.want != "" {
want = guardStopped + test.want + " is in baton's folder " + store.Dir +
", which only baton writes: write your notes in your session's files/ folder."
}

input := guardInput(t, linked, tool, args)
wantGuard(t, guardHook(t, linked, paneOf(flow.RoleReviewer), input), want)
})
}
}
}

// TestGuardHookFindsBatonsFolderFromItsOwnFolder finds baton's folder from the hook's own folder
// when the input names no folder, and a relative path starts there.
func TestGuardHookFindsBatonsFolderFromItsOwnFolder(t *testing.T) {
mainDir, linked := repos(t)
store := started(t, mainDir, fresh())

input := guardInput(t, "", "create", map[string]any{"path": "../main/.git/baton/x.md"})
wantGuard(
t,
guardHook(t, linked, paneOf(flow.RoleLead), input),
guardStopped+store.Dir+"/x.md is in baton's folder "+
store.Dir+", which only baton writes: write your notes in your session's files/ folder.",
)
}

// TestGuardHookLetsTheFileToolsWriteWhereBatonHasNoFolder lets a role's create and edit write in a
// repo without baton's folder, even where the folder would be, since baton keeps nothing there.
func TestGuardHookLetsTheFileToolsWriteWhereBatonHasNoFolder(t *testing.T) {
other := repoNamed(t, "other")
input := guardInput(t, other, "create", map[string]any{"path": other + "/.git/baton/x.md"})
wantGuard(t, guardHook(t, other, paneOf(flow.RoleLead), input), "")
}

// TestGuardHookFailsAndDenies fails, with the reason on stderr and nothing on stdout, when it
// can't tell whether to deny the tool; copilot then denies it.
func TestGuardHookFailsAndDenies(t *testing.T) {
mainDir, linked := repos(t)
started(t, mainDir, fresh())

outside := t.TempDir()
t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(outside))

inLead := paneOf(flow.RoleLead)
push := guardInput(t, linked, "bash", map[string]any{"command": "git push"})
cases := []struct {
name string
args []string
env map[string]string
input string
want string // The start of the reason, after "baton hook guard: ".
}{
{"with an argument", []string{"now"}, inLead, push, `unexpected argument "now"`},
{"without input", nil, inLead, "", "read the hook's input: EOF"},
{"with input that isn't JSON", nil, inLead, "bash", "read the hook's input: jsontext: "},
{"with input that isn't an object", nil, inLead, "[]", "read the hook's input: json: "},
{
"outside a pane", nil, nil, push,
"want BATON_ROLE: run it in a pane that baton started, which names its role there\n",
},
{
"with bash's arguments a string", nil, inLead,
guardInput(t, linked, "bash", "git push"), "read the arguments of bash: json: ",
},
{
"without bash's arguments", nil, inLead,
`{"cwd":"` + linked + `","toolName":"bash"}`,
"read the arguments of bash: jsontext: unexpected EOF",
},
{
"with a path that isn't a string", nil, inLead,
guardInput(t, linked, "edit", map[string]any{"path": 1}),
"read the arguments of edit: json: ",
},
{
"outside a git repo", nil, inLead,
guardInput(t, outside, "create", map[string]any{"path": outside + "/x.md"}),
"find the git repo: fatal: not a git repository",
},
}

for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
hook := guardEnv(linked, test.env, test.input)
hook.Args = append(hook.Args, test.args...)
got := runEnv(t.Context(), hook)

want := "baton hook guard: " + test.want
if got.code != 1 || got.stdout != "" || !strings.HasPrefix(got.stderr, want) {
t.Errorf("got %+v; want exit code 1 and %q", got, want)
}
})
}
}

// TestGuardHookReportsAnOutputItCantWrite fails when it can't write its answer, which copilot
// then doesn't get, so that copilot denies the tool.
func TestGuardHookReportsAnOutputItCantWrite(t *testing.T) {
var stderr strings.Builder

hook := guardEnv(t.TempDir(), paneOf(flow.RoleQA),
guardInput(t, "", "bash", map[string]any{"command": "git push"}))
hook.Stdout, hook.Stderr = failing{}, &stderr

code := cli.Run(t.Context(), hook)
if got := stderr.String(); code != 1 ||
got != "baton hook guard: jsontext: write error: output closed\n" {
t.Errorf("exit code %d and %q; want 1 and the output's error", code, got)
}
}
Loading
Loading