diff --git a/README.md b/README.md index c357c92..5ede7cf 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ tinyagent --read-only --cwd ~/src/project -t "Review error handling in pkg/api" cat review-task.md | tinyagent --read-only -t - -o review.json ``` -Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and `tinyagent --help --verbose` for every flag. Subcommands are shortcuts for common flags: `tinyagent sandbox`, `tinyagent memory list`, `tinyagent planner --first`, `tinyagent plugins list`, `tinyagent tinygo status` (see [CLI reference](docs/subcommands.md)); each one alone, such as `tinyagent sandbox`, prints its help. +Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and `tinyagent --help --verbose` for every flag. Subcommands are shortcuts for common flags: `tinyagent sandbox`, `tinyagent memory list`, `tinyagent ratchet list`, `tinyagent planner --first`, `tinyagent plugins list`, `tinyagent tinygo status` (see [CLI reference](docs/subcommands.md)); each one alone, such as `tinyagent sandbox`, prints its help. ## Features @@ -114,6 +114,7 @@ Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and - Many providers: OpenAI, Anthropic, OpenRouter, Ollama, a ChatGPT subscription through Codex, and your own OpenAI-compatible endpoints. - Streaming text, `--json` event output, structured output, SQLite event logs and OpenTelemetry. - Project memory across runs, with approval before entries become active. +- Project rules from agent failures (`tinyagent ratchet add`): each rule records its incident and an expiry or review condition, is added to the system prompt, and can be enforced as a permission deny rule; see [Project rules](docs/ratchet.md). - A task planner that can require a plan before any change. - Run budgets: turn cap, cost limit and token limit (`--token-limit`), with an 80% warning to the model; delegates, workers and `--attempts` share the budget, and a run that hits it can be resumed. - Verify before stop (`--verify-on-stop`): a run that changed files isn't accepted until a test or build command passes; otherwise it is marked unverified. @@ -135,6 +136,7 @@ Run `tinyagent --help` (or plain `tinyagent` at a terminal) for the overview and | Verify before stop (`--verify-on-stop`) | [docs/verify.md](docs/verify.md) | | `undo_file` and `--rewind` | [docs/undo.md](docs/undo.md) | | Project memory | [docs/memory.md](docs/memory.md) | +| Project rules from agent failures (`ratchet`) | [docs/ratchet.md](docs/ratchet.md) | | Planner (`update_plan`, `--plan-first`) | [docs/planner.md](docs/planner.md) | | AGENTS.md, skills and skill workflows | [docs/project-context.md](docs/project-context.md) | | Plugins, WebAssembly modules, version store and TinyGo | [docs/plugins.md](docs/plugins.md) | diff --git a/cmd/tinyagent/help.go b/cmd/tinyagent/help.go index e083687..3896917 100644 --- a/cmd/tinyagent/help.go +++ b/cmd/tinyagent/help.go @@ -72,6 +72,7 @@ var helpGroups = []flagGroup{ {"Output and logging", [][3]string{{"o", "output", "path"}, {"v", "verbose", ""}, {"", "no-stream", ""}, {"", "json", ""}, {"", "output-schema", "path"}, {"", "log-db", "[=path]"}, {"", "otel", ""}, {"", "tool-output-files", ""}, {"", "redact-secrets", ""}, {"", "transcript", "path"}}}, {"Sandbox and permissions", [][3]string{{"", "read-only", ""}, {"", "add-dir-read", "path"}, {"", "add-dir-write", "path"}, {"", "sandbox", ""}, {"", "sandbox-network", ""}, {"", "sandbox-allow-domain", "host"}, {"", "env", "name"}, {"", "command-env", "mode"}, {"", "lockdown", ""}, {"", "web-fetch", ""}, {"", "web-fetch-allow-domain", "host"}}}, {"Context and tools", [][3]string{{"", "no-agents-md", ""}, {"", "scoped-agents-md", ""}, {"", "skills", ""}, {"", "no-skills", ""}, {"", "apply-patch", ""}, {"", "lsp", ""}, {"", "snapshots", ""}}}, + ratchetGroup, {"Memory and planner", [][3]string{{"", "memory", ""}, {"", "memory-approve", "id|all"}, {"", "memory-forget", "id|all"}, {"", "memory-help", ""}, {"", "memory-list", ""}, {"", "memory-path", ""}, {"", "memory-yes", ""}, {"", "plan", ""}, {"", "plan-file", "path"}, {"", "plan-first", ""}}}, {"Plugins and TinyGo", [][3]string{{"", "list-plugins", ""}, {"", "plugins-prune", ""}, {"", "plugins-keep", "n"}, {"", "plugins-rollback", "module@sha256"}, {"", "tinygo-install", ""}, {"", "tinygo-update", ""}, {"", "tinygo-status", ""}}}, {"Rewind", [][3]string{{"", "rewind", "path"}, {"", "to", "target"}, {"", "dry-run", ""}}}, diff --git a/cmd/tinyagent/main.go b/cmd/tinyagent/main.go index e008300..f1011fe 100644 --- a/cmd/tinyagent/main.go +++ b/cmd/tinyagent/main.go @@ -102,6 +102,7 @@ type options struct { acp bool workflow, workflowName string memory memoryCmd + ratchet ratchetFlags lockdown lockdownFlags attempts attemptFlags verify verifyFlags @@ -202,6 +203,7 @@ func newFlagSet(o *options) *flag.FlagSet { fs.StringVar(&o.workflow, "workflow", "", "run the skill workflow in this YAML file on the task (-t): the router picks a workflow or skill by intent, and each stage runs automatically with the previous stage's output, gated by its success check") fs.StringVar(&o.workflowName, "workflow-name", "", "with --workflow: run this workflow or skill instead of routing by the task's intent") registerMemoryFlags(fs, &o.memory) + registerRatchetFlags(fs, &o.ratchet) registerLockdownFlags(fs, &o.lockdown) registerAttemptFlags(fs, &o.attempts) return fs @@ -272,6 +274,9 @@ func run() int { if memoryCommandSet(fs) { return runMemoryCommand(&o, fs, os.Stdout, os.Stderr) } + if ratchetCommandSet(fs) { + return runRatchetCommand(&o, fs, os.Stdout, os.Stderr) + } if o.acp { if f := acpConflict(fs); f != "" { fmt.Fprintf(fs.Output(), "error: --acp can't be combined with %s: the editor supplies each session's prompts and cwd\n", f) @@ -612,6 +617,15 @@ func run() int { return configFail(err) } fileAccess.AddDirs(extraDirs) + rules, err := loadRatchet(o.ratchet.off, root, &cfg) + if err != nil { + return configFail(err) + } + if rules != nil && rules.enforced > 0 { + if policy, err = permissions.New(cfg.Permissions); err != nil { + return configFail(err) + } + } var reviewInfo *trajectory.ReviewInfo if review != nil { task, reviewInfo, err = collectReview(review, root, task, fileAccess, os.Stderr, o.verbose) @@ -789,6 +803,7 @@ func run() int { warn(trWarn) } startOutputFiles(outputFiles, cfg.ToolOutputFiles.MaxRuns, warn) + rules.start(a, events, warn) if cfg.Skills { loadSkills(a, root, pluginSkillSources(cfg), warn) } diff --git a/cmd/tinyagent/ratchet_cmd.go b/cmd/tinyagent/ratchet_cmd.go new file mode 100644 index 0000000..86ad07e --- /dev/null +++ b/cmd/tinyagent/ratchet_cmd.go @@ -0,0 +1,297 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/vicgupta/tinyagent/internal/agent" + "github.com/vicgupta/tinyagent/internal/config" + "github.com/vicgupta/tinyagent/internal/permissions" + "github.com/vicgupta/tinyagent/internal/ratchet" +) + +// ratchetFlags hold the project rules ("ratchet", issue #249) flags. +type ratchetFlags struct { + list, path bool + add, remove string + incident, expires, review string + denyTool, denyCommand, denyPath string + denyHost string + off bool +} + +// ratchetCommandFlags are the management commands; exactly one may be set. +var ratchetCommandFlags = []string{"ratchet-list", "ratchet-path", "ratchet-add", "ratchet-remove"} + +// ratchetAddFlags only apply to --ratchet-add. +var ratchetAddFlags = []string{"ratchet-incident", "ratchet-expires", "ratchet-review", "ratchet-deny-tool", "ratchet-deny-command", "ratchet-deny-path", "ratchet-deny-host"} + +// ratchetGroup lists the ratchet flags in --help --verbose. +var ratchetGroup = flagGroup{"Project rules (ratchet)", [][3]string{ + {"", "ratchet-add", "rule"}, {"", "ratchet-incident", "text"}, {"", "ratchet-expires", "date"}, {"", "ratchet-review", "text"}, + {"", "ratchet-deny-tool", "glob"}, {"", "ratchet-deny-command", "glob"}, {"", "ratchet-deny-path", "pattern"}, {"", "ratchet-deny-host", "host"}, + {"", "ratchet-list", ""}, {"", "ratchet-path", ""}, {"", "ratchet-remove", "id"}, {"", "no-ratchet", ""}, +}} + +func registerRatchetFlags(fs *flag.FlagSet, r *ratchetFlags) { + fs.StringVar(&r.add, "ratchet-add", "", "record a project rule with this text in --cwd's "+ratchet.RelPath+" and exit; needs --ratchet-incident; without --ratchet-expires or --ratchet-review it expires in 90 days") + fs.StringVar(&r.incident, "ratchet-incident", "", "with --ratchet-add: the failure that led to the rule (required)") + fs.StringVar(&r.expires, "ratchet-expires", "", "with --ratchet-add: YYYY-MM-DD date from which the rule is skipped and flagged as expired") + fs.StringVar(&r.review, "ratchet-review", "", "with --ratchet-add: when to reconsider the rule (e.g. \"when the default model changes\"); with it and no --ratchet-expires the rule has no expiry date") + fs.StringVar(&r.denyTool, "ratchet-deny-tool", "", "with --ratchet-add: also enforce the rule as a permissions deny rule for these tools (\"|\"-separated globs; defaults by matcher: bash|start_process, write_file|edit_file|apply_patch, web_fetch)") + fs.StringVar(&r.denyCommand, "ratchet-deny-command", "", "with --ratchet-add: also enforce the rule by denying shell commands matching this glob (e.g. \"git push *\")") + fs.StringVar(&r.denyPath, "ratchet-deny-path", "", "with --ratchet-add: also enforce the rule by denying file edits to paths matching this pattern (file_access syntax)") + fs.StringVar(&r.denyHost, "ratchet-deny-host", "", "with --ratchet-add: also enforce the rule by denying web_fetch to this host (sandbox_allowed_domains syntax)") + fs.BoolVar(&r.list, "ratchet-list", false, "print the project rules in --cwd's "+ratchet.RelPath+" with their status (active or expired), incident and expiry or review condition (--json for a JSON array), then exit") + fs.BoolVar(&r.path, "ratchet-path", false, "print the project rules file path for --cwd and exit") + fs.StringVar(&r.remove, "ratchet-remove", "", "delete the project rule with this id and exit") + fs.BoolVar(&r.off, "no-ratchet", false, "don't load the project rules in "+ratchet.RelPath+" for this run (no guidance, no enforced deny rules)") +} + +// ratchetCommandSet reports whether a ratchet management flag was given. +func ratchetCommandSet(fs *flag.FlagSet) bool { + return flagWasSet(fs, ratchetCommandFlags...) || flagWasSet(fs, ratchetAddFlags...) +} + +// ratchetAllowedFlags are the only other flags a ratchet command accepts. +var ratchetAllowedFlags = map[string]bool{"cwd": true, "json": true} + +// ratchetNow is the clock for created/expiry dates; tests replace it. +var ratchetNow = time.Now + +// runRatchetCommand implements the ratchet management flags. Usage errors +// exit 2; a rules file that can't be read or written exits 1. +func runRatchetCommand(o *options, fs *flag.FlagSet, stdout, stderr io.Writer) int { + var set []string + for _, name := range ratchetCommandFlags { + if flagWasSet(fs, name) { + set = append(set, "--"+name) + } + } + if len(set) == 0 { + fmt.Fprintln(stderr, "error: the --ratchet-incident, --ratchet-expires, --ratchet-review and --ratchet-deny-* flags apply only to --ratchet-add") + return 2 + } + if len(set) > 1 { + fmt.Fprintf(stderr, "error: %s can't be combined; run one ratchet command at a time\n", strings.Join(set, " and ")) + return 2 + } + r := o.ratchet + var conflict string + fs.Visit(func(f *flag.Flag) { + if conflict != "" || ratchetAllowedFlags[f.Name] { + return + } + for _, n := range ratchetCommandFlags { + if f.Name == n { + return + } + } + for _, n := range ratchetAddFlags { + if f.Name == n && flagWasSet(fs, "ratchet-add") { + return + } + } + conflict = f.Name + }) + if conflict != "" { + dash := "--" + if len(conflict) == 1 { + dash = "-" + } + fmt.Fprintf(stderr, "error: %s only manages the project rules file; it can't be combined with %s%s\n", set[0], dash, conflict) + return 2 + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "error: unexpected positional argument %q\n", fs.Arg(0)) + return 2 + } + if o.jsonOutput && !r.list { + fmt.Fprintln(stderr, "error: --json applies only to --ratchet-list among the ratchet commands") + return 2 + } + if fi, err := os.Stat(o.cwd); err != nil || !fi.IsDir() { + fmt.Fprintf(stderr, "error: --cwd %q is not a directory\n", o.cwd) + return 2 + } + root, err := canonicalRoot(o.cwd) + if err != nil { + fmt.Fprintln(stderr, "error: ratchet:", err) + return 1 + } + path := ratchet.Path(root) + switch { + case r.path: + fmt.Fprintln(stdout, path) + return 0 + case flagWasSet(fs, "ratchet-add"): + rule := ratchet.Rule{Rule: strings.TrimSpace(r.add), Incident: strings.TrimSpace(r.incident), Expires: r.expires, Review: strings.TrimSpace(r.review)} + if rule.Rule == "" { + fmt.Fprintln(stderr, "error: --ratchet-add needs the rule text") + return 2 + } + if rule.Incident == "" { + fmt.Fprintln(stderr, "error: --ratchet-add needs --ratchet-incident: what went wrong that this rule prevents") + return 2 + } + if r.denyTool != "" || r.denyCommand != "" || r.denyPath != "" || r.denyHost != "" { + rule.Deny = &ratchet.Deny{Tool: r.denyTool, Command: r.denyCommand, Path: r.denyPath, Host: r.denyHost} + } + probe := rule + probe.Created, probe.Expires = ratchetNow().Format(ratchet.DateLayout), "2000-01-01" + if rule.Expires != "" { + probe.Expires = rule.Expires + } + if err := probe.Validate(); err != nil { + fmt.Fprintln(stderr, "error: --ratchet-add:", err) + return 2 + } + if rule.Deny != nil { + if err := checkRatchetDeny(rule); err != nil { + fmt.Fprintln(stderr, "error: --ratchet-add:", err) + return 2 + } + } + added, err := ratchet.Add(path, rule, ratchetNow()) + if err != nil { + fmt.Fprintln(stderr, "error: ratchet:", err) + return 1 + } + fmt.Fprintf(stdout, "added rule %s to %s (%s)\n", added.ID, path, ratchetLimit(added)) + return 0 + case flagWasSet(fs, "ratchet-remove"): + id := strings.TrimSpace(r.remove) + if id == "" { + fmt.Fprintln(stderr, "error: --ratchet-remove needs a rule id (see --ratchet-list)") + return 2 + } + if err := ratchet.Remove(path, id); err != nil { + fmt.Fprintln(stderr, "error: ratchet:", err) + return 1 + } + fmt.Fprintf(stdout, "removed rule %s from %s\n", id, path) + return 0 + } + rules, err := ratchet.Load(path) + if err != nil { + fmt.Fprintln(stderr, "error: ratchet:", err) + return 1 + } + now := ratchetNow() + if o.jsonOutput { + out := []map[string]any{} + for _, x := range rules { + m := map[string]any{"id": x.ID, "rule": x.Rule, "incident": x.Incident, "created": x.Created, "status": ratchetStatus(x, now), "enforced": x.Deny != nil} + if x.Expires != "" { + m["expires"] = x.Expires + } + if x.Review != "" { + m["review"] = x.Review + } + if x.Deny != nil { + m["deny"] = ratchet.PermissionRules([]ratchet.Rule{x})[0] + } + out = append(out, m) + } + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + return 1 + } + return 0 + } + if len(rules) == 0 { + fmt.Fprintf(stdout, "no project rules (%s)\n", path) + return 0 + } + for _, x := range rules { + enforced := "" + if x.Deny != nil { + enforced = " [enforced]" + } + fmt.Fprintf(stdout, "%s %s %s %s%s\n incident: %s\n %s\n", x.ID, ratchetStatus(x, now), x.Created, x.Rule, enforced, x.Incident, ratchetLimit(x)) + } + return 0 +} + +func ratchetStatus(r ratchet.Rule, now time.Time) string { + if r.Expired(now) { + return "expired" + } + return "active" +} + +func ratchetLimit(r ratchet.Rule) string { + var parts []string + if r.Expires != "" { + parts = append(parts, "expires "+r.Expires) + } + if r.Review != "" { + parts = append(parts, "review: "+r.Review) + } + return strings.Join(parts, "; ") +} + +// checkRatchetDeny compiles a rule's deny matcher with the permission +// policy so a bad pattern is refused when it is recorded. +func checkRatchetDeny(r ratchet.Rule) error { + r.ID = "new" + _, err := permissions.New(config.Permissions{Rules: ratchet.PermissionRules([]ratchet.Rule{r})}) + return err +} + +// ratchetRun is the project rules a fresh run loaded. +type ratchetRun struct { + path string + active, expired []ratchet.Rule + enforced int +} + +// loadRatchet reads root's project rules unless off, and appends a deny +// rule to cfg.Permissions for each active rule with a matcher. It returns +// nil when off or when the file is missing or empty, changing nothing. +func loadRatchet(off bool, root string, cfg *config.AgentConfig) (*ratchetRun, error) { + if off { + return nil, nil + } + path := ratchet.Path(root) + rules, err := ratchet.Load(path) + if err != nil { + return nil, &config.InvalidError{Err: fmt.Errorf("project rules: %w (fix the file, or run with --no-ratchet)", err)} + } + if len(rules) == 0 { + return nil, nil + } + rr := &ratchetRun{path: path} + rr.active, rr.expired = ratchet.Split(rules, ratchetNow()) + deny := ratchet.PermissionRules(rr.active) + rr.enforced = len(deny) + cfg.Permissions.Rules = append(cfg.Permissions.Rules, deny...) + return rr, nil +} + +// start puts the active rules in a's system prompt, warns about each +// expired rule, and emits ratchet.loaded. +func (rr *ratchetRun) start(a *agent.Agent, events *runEvents, warn func(string)) { + if rr == nil { + return + } + a.Ratchet = ratchet.Section(rr.active) + ids, expired := []string{}, []string{} + for _, r := range rr.active { + ids = append(ids, r.ID) + } + for _, r := range rr.expired { + expired = append(expired, r.ID) + warn(fmt.Sprintf("project rule %s expired on %s and was skipped; review it, then remove it (--ratchet-remove %s) or re-add it with a new expiry", r.ID, r.Expires, r.ID)) + } + events.emit("ratchet.loaded", map[string]any{"path": rr.path, "loaded": len(rr.active), "ids": ids, "expired": expired, "enforced": rr.enforced}) + if a.Verbose { + fmt.Fprintf(a.Out, "project rules: loaded %d (%d enforced, %d expired) from %s\n", len(rr.active), rr.enforced, len(rr.expired), rr.path) + } +} diff --git a/cmd/tinyagent/ratchet_cmd_test.go b/cmd/tinyagent/ratchet_cmd_test.go new file mode 100644 index 0000000..a2d0428 --- /dev/null +++ b/cmd/tinyagent/ratchet_cmd_test.go @@ -0,0 +1,217 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/vicgupta/tinyagent/internal/ratchet" +) + +func ratchetEnv(t *testing.T) string { + t.Helper() + cwd, _ := memoryEnv(t) + old := ratchetNow + ratchetNow = func() time.Time { return time.Date(2026, 10, 30, 9, 0, 0, 0, time.UTC) } + t.Cleanup(func() { ratchetNow = old }) + return cwd +} + +func runRatchetCLI(t *testing.T, args ...string) (code int, stdout, stderr string) { + t.Helper() + stdout = captureStdout(t, func() { + stderr = captureStderr(t, func() { + var flagErr string + code, _, flagErr = runWith(t, args...) + os.Stderr.WriteString(flagErr) + }) + }) + return +} + +func TestRatchetAddListRemove(t *testing.T) { + cwd := ratchetEnv(t) + code, out, errOut := runRatchetCLI(t, "ratchet", "add", "Never push to main", "--incident", "pushed a broken commit to main", "--deny-command", "git push * main", "--cwd", cwd) + if code != 0 || !strings.Contains(out, "added rule r1") || !strings.Contains(out, "expires 2027-01-28") { + t.Fatalf("add: exit %d\nstdout %s\nstderr %s", code, out, errOut) + } + code, out, errOut = runRatchetCLI(t, "--ratchet-add", "Run go vet", "--ratchet-incident", "vet failed in CI", "--ratchet-review", "when CI runs vet on every push", "--cwd", cwd) + if code != 0 || !strings.Contains(out, "added rule r2") || !strings.Contains(out, "review: when CI") { + t.Fatalf("add r2: exit %d\nstdout %s\nstderr %s", code, out, errOut) + } + code, out, _ = runRatchetCLI(t, "ratchet", "list", "--cwd", cwd) + if code != 0 || !strings.Contains(out, "r1 active 2026-10-30 Never push to main [enforced]") || !strings.Contains(out, "incident: vet failed in CI") { + t.Fatalf("list: exit %d\n%s", code, out) + } + code, out, _ = runRatchetCLI(t, "ratchet", "list", "--json", "--cwd", cwd) + var rows []map[string]any + if err := json.Unmarshal([]byte(out), &rows); code != 0 || err != nil || len(rows) != 2 || rows[0]["enforced"] != true || rows[1]["review"] == nil { + t.Fatalf("list --json: exit %d err %v\n%s", code, err, out) + } + code, out, _ = runRatchetCLI(t, "ratchet", "path", "--cwd", cwd) + if code != 0 || !strings.HasSuffix(strings.TrimSpace(out), filepath.FromSlash(ratchet.RelPath)) { + t.Fatalf("path: %d %q", code, out) + } + code, out, _ = runRatchetCLI(t, "ratchet", "remove", "r1", "--cwd", cwd) + if code != 0 || !strings.Contains(out, "removed rule r1") { + t.Fatalf("remove: %d %q", code, out) + } + rules, err := ratchet.Load(ratchet.Path(testRoot(t, cwd))) + if err != nil || len(rules) != 1 || rules[0].ID != "r2" { + t.Fatalf("after remove: %+v %v", rules, err) + } +} + +func testRoot(t *testing.T, cwd string) string { + t.Helper() + root, err := canonicalRoot(cwd) + if err != nil { + t.Fatal(err) + } + return root +} + +func TestRatchetUsageErrors(t *testing.T) { + cwd := ratchetEnv(t) + for _, args := range [][]string{ + {"--ratchet-add", "x"}, + {"--ratchet-incident", "x"}, + {"--ratchet-add", "x", "--ratchet-incident", "y", "--ratchet-expires", "tomorrow"}, + {"--ratchet-add", "x", "--ratchet-incident", "y", "--ratchet-deny-command", "a", "--ratchet-deny-path", "b"}, + {"--ratchet-add", "x", "--ratchet-incident", "y", "--ratchet-deny-host", "bad host!"}, + {"--ratchet-list", "--ratchet-path"}, + {"--ratchet-list", "-t", "task"}, + {"--ratchet-path", "--json"}, + {"--ratchet-remove", " "}, + } { + code, _, _ := runRatchetCLI(t, append(args, "--cwd", cwd)...) + if code != 2 { + t.Errorf("%v: exit %d, want 2", args, code) + } + } + if _, err := os.Stat(ratchet.Path(testRoot(t, cwd))); !os.IsNotExist(err) { + t.Fatalf("usage errors created the rules file: %v", err) + } + if code, _, _ := runRatchetCLI(t, "--ratchet-remove", "r9", "--cwd", cwd); code != 1 { + t.Errorf("remove unknown: exit %d, want 1", code) + } +} + +// ratchetModel is a fake model that first runs a bash command and then +// finishes, recording every request body. +func ratchetModel(t *testing.T, command string) func() []string { + t.Helper() + var mu sync.Mutex + var bodies []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + bodies = append(bodies, string(b)) + n := len(bodies) + mu.Unlock() + if n == 1 && command != "" { + args, _ := json.Marshal(map[string]string{"command": command}) + resp, _ := json.Marshal(map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "", "tool_calls": []any{map[string]any{"id": "c1", "type": "function", "function": map[string]any{"name": "bash", "arguments": string(args)}}}}, "finish_reason": "tool_calls"}}, + "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1}, + }) + w.Write(resp) + return + } + io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"done\nCOMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`) + })) + t.Cleanup(srv.Close) + t.Setenv("OPENAI_BASE_URL", srv.URL) + t.Setenv("OPENAI_API_KEY", "fake") + return func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), bodies...) + } +} + +func seedRatchet(t *testing.T, cwd, body string) { + t.Helper() + p := ratchet.Path(testRoot(t, cwd)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +const ratchetSeed = ` +[[rule]] +id = "r1" +rule = "Don't delete the keep directory" +incident = "an agent wiped keep/ during cleanup" +created = "2026-10-01" +review = "when keep/ is generated" +[rule.deny] +command = "rm *" + +[[rule]] +id = "r2" +rule = "stale-rule-text" +incident = "old" +created = "2026-01-01" +expires = "2026-06-01" +` + +func TestRatchetRunInjectsAndEnforces(t *testing.T) { + cwd := ratchetEnv(t) + bodies := ratchetModel(t, "rm -rf keep") + seedRatchet(t, cwd, ratchetSeed) + keep := filepath.Join(cwd, "keep", "f") + os.MkdirAll(filepath.Dir(keep), 0o755) + os.WriteFile(keep, []byte("x"), 0o644) + code, stdout, stderr := runMemoryTaskOut(t, cwd, "--json") + if code != 0 { + t.Fatalf("exit %d\nstderr %s", code, stderr) + } + if _, err := os.Stat(keep); err != nil { + t.Fatalf("denied command ran: %v", err) + } + b := bodies() + if len(b) < 2 { + t.Fatalf("want 2 requests, got %d", len(b)) + } + if !strings.Contains(b[0], "Project rules") || !strings.Contains(b[0], "[r1] Don't delete the keep directory") || strings.Contains(b[0], "stale-rule-text") { + t.Fatalf("system prompt missing rules or has expired rule:\n%s", b[0]) + } + if !strings.Contains(b[1], "blocked by project rule r1") { + t.Fatalf("deny message not shown to model:\n%s", b[1]) + } + if !strings.Contains(stdout, `"ratchet.loaded"`) || !strings.Contains(stdout, `"expired":["r2"]`) || !strings.Contains(stdout, "project rule r2 expired") { + t.Fatalf("events:\n%s", stdout) + } +} + +func TestRatchetRunOffAndAbsent(t *testing.T) { + cwd := ratchetEnv(t) + bodies := ratchetModel(t, "") + code, stdout, stderr := runMemoryTaskOut(t, cwd, "--json") + if code != 0 || strings.Contains(stdout, "ratchet.loaded") || strings.Contains(bodies()[0], "Project rules") { + t.Fatalf("no rules file: exit %d\n%s\n%s", code, stdout, stderr) + } + if _, err := os.Stat(filepath.Join(cwd, ".tinyagent")); !os.IsNotExist(err) { + t.Fatalf("a run without rules created .tinyagent: %v", err) + } + seedRatchet(t, cwd, ratchetSeed) + code, stdout, _ = runMemoryTaskOut(t, cwd, "--json", "--no-ratchet") + if code != 0 || strings.Contains(stdout, "ratchet.loaded") || strings.Contains(bodies()[1], "Project rules") { + t.Fatalf("--no-ratchet: exit %d\n%s", code, stdout) + } + seedRatchet(t, cwd, "[[rule]\n") + if code, _, stderr := runMemoryTaskOut(t, cwd); code != 2 || !strings.Contains(stderr, "--no-ratchet") { + t.Fatalf("corrupt file: exit %d\n%s", code, stderr) + } +} diff --git a/cmd/tinyagent/subcommands.go b/cmd/tinyagent/subcommands.go index ea12d8c..b3418cf 100644 --- a/cmd/tinyagent/subcommands.go +++ b/cmd/tinyagent/subcommands.go @@ -65,6 +65,16 @@ var subcommands = []subcommand{ }, options: map[string]string{"list": "memory-list", "path": "memory-path", "approve": "memory-approve", "forget": "memory-forget", "yes": "memory-yes", "config-help": "memory-help"}, }, + { + name: "ratchet", brief: "record project rules from agent failures: add|list|remove|path", summary: "record and manage project rules learned from agent failures, each with its incident and expiry (same as --ratchet-*)", + actions: map[string]subAction{ + "add": {flag: "ratchet-add", arg: true, desc: "record a rule with this text; needs --incident (--ratchet-add)"}, + "list": {flag: "ratchet-list", desc: "print every rule with its status, incident and expiry, then exit (--ratchet-list)"}, + "remove": {flag: "ratchet-remove", arg: true, desc: "delete a rule by id (--ratchet-remove)"}, + "path": {flag: "ratchet-path", desc: "print the rules file path and exit (--ratchet-path)"}, + }, + options: map[string]string{"incident": "ratchet-incident", "expires": "ratchet-expires", "review": "ratchet-review", "deny-tool": "ratchet-deny-tool", "deny-command": "ratchet-deny-command", "deny-path": "ratchet-deny-path", "deny-host": "ratchet-deny-host"}, + }, { name: "planner", brief: "run a task with the update_plan planner (--first, --file)", summary: "run with the task planner (update_plan) on (same as --plan)", base: []string{"--plan"}, diff --git a/docs/json-output.md b/docs/json-output.md index db5c664..c50a4fa 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -26,6 +26,7 @@ Every event has a `type`. Event names and fields in schema version 1 are: | `sandbox.network_denied` | `host`, `port`, `reason`; the sandbox allowlist proxy refused a request from a command (see [Network allowlist](sandbox.md#network-allowlist)) | | `web_fetch` | `url`, `final_url`, `status`, `content_type`, `bytes`, `truncated`, `duration_ms`, and `denied_reason` (for a refused URL) or `error` (for a failed fetch); one per fetch attempt, emitted before its `tool.result` (see [Web fetch](long-runs.md#web-fetch)) | | `memory.loaded` | `path` (the store file), `loaded` (entry count), `ids` (loaded entry ids, newest first), `omitted` (active entries left out to stay within `memory.max_bytes`); once after `run.started` for a fresh run with [project memory](memory.md#project-memory) on. A resumed or follow-up run of a checkpoint that had memory on emits it too, describing the replayed checkpoint snapshot (the store is not re-read), with `from_checkpoint: true`. Never includes entry text | +| `ratchet.loaded` | `path` (the rules file), `loaded` (active rule count), `ids` (active rule ids), `expired` (ids of expired rules that were skipped), `enforced` (active rules added as permission deny rules); once after `run.started` for a fresh run when `.tinyagent/ratchet.toml` exists and `--no-ratchet` isn't set. See [Project rules](ratchet.md). Never includes rule text | | `budget.warning` | `step`, `usage` (what was used, as text), `tokens`, `token_limit`, `cost_usd`, `cost_limit`; once per run when a token or cost budget reaches 80% (see [Token and cost budgets](runs.md#token-and-cost-budgets)) | | `verify.reprompt` | `reprompt`, `max_reprompts`; the model gave a final answer with unverified changes and was sent back to verify (see [Verify before stop](verify.md#verify-before-stop)) | | `verify.unverified` | `reason`, `reprompts`; the run finishes with unverified changes | diff --git a/docs/permissions.md b/docs/permissions.md index b24858d..ae57445 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -42,7 +42,7 @@ An `ask` is answered through the channel the run has: - **ACP** (`--acp`): tinyagent sends `session/request_permission` with the call's `toolCallId` and the `allow_once`, `allow_always` and `reject_once` options; the editor's Approve/Reject buttons answer it. A `cancelled` outcome cancels the turn like `session/cancel`. - **Non-interactive** (no terminal, `--json`, piped CI/GitHub Action runs): an `ask` resolves to `permissions.non_interactive` (`deny` by default), so nobody can answer but the run continues. -A denied call does not run and no `pre_tool` hook runs for it; the model gets `error: denied by permission rule : ` (or `... by user: `), and the run continues. An allowed call then runs its [`pre_tool` hooks](#tool-hooks) as usual, which can still deny it — but a hook can never cause a policy-denied call to run. Rules come only from the user config, `-c` or a profile, never from the repository; a profile's `rules` replace the base list. Validation errors (unknown keys, an empty `tool`, an invalid decision, two matchers in one rule, a matcher/tool mismatch) exit 2 before any model request, naming the file, profile and rule index. +A denied call does not run and no `pre_tool` hook runs for it; the model gets `error: denied by permission rule : ` (or `... by user: `), and the run continues. An allowed call then runs its [`pre_tool` hooks](#tool-hooks) as usual, which can still deny it — but a hook can never cause a policy-denied call to run. Rules come only from the user config, `-c` or a profile, never from the repository, with one exception: [project rules](ratchet.md) with a `deny` table in `.tinyagent/ratchet.toml` are appended as `deny` rules (they can only block calls, never allow them; `--no-ratchet` skips them). A profile's `rules` replace the base list. Validation errors (unknown keys, an empty `tool`, an invalid decision, two matchers in one rule, a matcher/tool mismatch) exit 2 before any model request, naming the file, profile and rule index. Every decision is recorded: a `permission` event goes to `--json`, `--log-db` and `--otel` (with `tool_call_id`, `tool_name`, `decision`, `source` (`rule`, `default`, `user`, `session` or `non_interactive`), the rule index, and the channel for asks), `run.started` and trajectories record a `permissions` summary, `-v` prints one stderr line per non-default decision, and `--check` prints the rule count, default and resolved non-interactive behavior. diff --git a/docs/ratchet.md b/docs/ratchet.md new file mode 100644 index 0000000..234e4c1 --- /dev/null +++ b/docs/ratchet.md @@ -0,0 +1,57 @@ +# Project rules (ratchet) + +When an agent makes a mistake in your project, record a rule so later runs don't repeat it. Each rule keeps the incident that caused it and an expiry date or a review condition, so the rule set stays explainable and can be pruned instead of growing forever. + +```sh +tinyagent ratchet add "Never push directly to main" \ + --incident "a run pushed an untested commit to main on 2026-10-29" \ + --deny-command "git push * main" +tinyagent ratchet add "Run go vet before submitting" \ + --incident "CI failed on a vet error the agent didn't check" \ + --review "when CI runs vet as a pre-commit hook" +tinyagent ratchet list # every rule with status, incident and expiry +tinyagent ratchet list --json # the same as a JSON array +tinyagent ratchet remove r1 # prune a rule +tinyagent ratchet path # print the rules file path +``` + +The flag forms are `--ratchet-add`, `--ratchet-incident`, `--ratchet-expires`, `--ratchet-review`, `--ratchet-deny-tool`, `--ratchet-deny-command`, `--ratchet-deny-path`, `--ratchet-deny-host`, `--ratchet-list`, `--ratchet-path` and `--ratchet-remove`; see the [CLI reference](subcommands.md#flags). These commands make no model request and need no credentials. They accept only `--cwd` (and `--json` with `list`); anything else exits 2. + +## The rules file + +Rules live in `.tinyagent/ratchet.toml` at the project root (`--cwd`). It's a plain TOML file you can review and edit; commit it so everyone (and CI) gets the same rules. If your `.gitignore` excludes `.tinyagent/`, add `!.tinyagent/ratchet.toml`. + +```toml +[[rule]] +id = "r1" +rule = "Never push directly to main" +incident = "a run pushed an untested commit to main on 2026-10-29" +created = "2026-10-30" +expires = "2027-01-28" +[rule.deny] +command = "git push * main" +``` + +| Key | Required | | +|---|---|---| +| `id` | yes | Unique id; `ratchet add` assigns `r1`, `r2`, ... | +| `rule` | yes | The instruction given to the agent | +| `incident` | yes | What went wrong that led to the rule | +| `created` | yes | `YYYY-MM-DD` | +| `expires` | one of `expires`/`review` | `YYYY-MM-DD`; from this date the rule is skipped | +| `review` | one of `expires`/`review` | When to reconsider the rule, e.g. "when the default model changes" | +| `deny` | no | Enforcement, below | + +`ratchet add` without `--expires` or `--review` sets `expires` to 90 days after today, so no rule lives forever by accident. Unknown keys, a missing required key, a bad date or a duplicate id make the file invalid. + +## What a run does with the rules + +A fresh run loads the file automatically when it exists; without a file nothing changes and nothing is created. `--no-ratchet` skips it for one run. + +- **Guidance.** Active rules are added to the system prompt as a "Project rules" section after [project memory](memory.md#project-memory), each with its id and incident. +- **Expired rules** are skipped, and the run prints one warning per expired rule (`project rule r2 expired on 2026-06-01 and was skipped; review it, then remove it (--ratchet-remove r2) or re-add it with a new expiry`) so stale rules get pruned or renewed. +- **Enforcement.** A rule with a `deny` table is also added as a deny rule to the [permission policy](permissions.md#permissions), with the message `blocked by project rule : (incident: ...)`. Deny wins over every allow, so the model sees the block and the call doesn't run. Set at most one matcher: `command` (shell command glob), `path` (file edits, `file_access` pattern syntax) or `host` (`web_fetch` host). `tool` overrides which tools it applies to (`|`-separated globs); by default that's `bash|start_process` for `command`, `write_file|edit_file|apply_patch` for `path` and `web_fetch` for `host`. A `tool` alone denies those tools outright. Patterns are checked when the rule is added. +- **Events.** With `--json`, a `ratchet.loaded` event reports the loaded ids, expired ids and how many rules are enforced; see [JSON output](json-output.md#machine-readable-output). `--verbose` prints a one-line summary. +- **Invalid file.** A rules file that can't be parsed or validated stops the run with exit 2 before any model request, rather than silently dropping enforcement. Fix the file or pass `--no-ratchet`. + +`--resume` and follow-up runs replay the system prompt saved in the checkpoint, so rules added afterwards apply to the next fresh run. [ACP](integrations.md#editor-integration-acp) sessions don't load project rules yet. diff --git a/docs/subcommands.md b/docs/subcommands.md index f1dd839..4fa0b05 100644 --- a/docs/subcommands.md +++ b/docs/subcommands.md @@ -2,7 +2,7 @@ ## Subcommands -Five subcommands are shortcuts for existing flags. They rewrite the command line into those flags before parsing, so config, environment variables and precedence work the same either way, and every `--flag` form keeps working. +Six subcommands are shortcuts for existing flags. They rewrite the command line into those flags before parsing, so config, environment variables and precedence work the same either way, and every `--flag` form keeps working. | Subcommand | Same as | |---|---| @@ -12,6 +12,8 @@ Five subcommands are shortcuts for existing flags. They rewrite the command line | `tinyagent memory list` / `path` / `config` | `--memory-list` / `--memory-path` / `--memory-help` | | `tinyagent memory approve ` | `--memory-approve ` | | `tinyagent memory forget [--yes]` | `--memory-forget [--memory-yes]` | +| `tinyagent ratchet add "" --incident "..." [--expires YYYY-MM-DD] [--review "..."] [--deny-tool t] [--deny-command c] [--deny-path p] [--deny-host h]` | `--ratchet-add "" --ratchet-incident "..." [--ratchet-expires ...] [--ratchet-review ...] [--ratchet-deny-tool ...] [--ratchet-deny-command ...] [--ratchet-deny-path ...] [--ratchet-deny-host ...]` | +| `tinyagent ratchet list` / `path` / `remove ` | `--ratchet-list` / `--ratchet-path` / `--ratchet-remove ` | | `tinyagent planner [--first] [--file plan.md] -t "..."` | `--plan [--plan-first] [--plan-file plan.md]` | | `tinyagent plugins list` | `--list-plugins` | | `tinyagent plugins rollback ` | `--plugins-rollback @` | @@ -21,7 +23,8 @@ Five subcommands are shortcuts for existing flags. They rewrite the command line - Any other flag (`-t`, `-m`, `--cwd`, ...) can follow. `--` followed by words is the task: `tinyagent planner --first -- fix the bug`. - The first word counts as a subcommand only when it is exactly the name and is alone or followed by an option, `--`, a known action or `help`. So `tinyagent memory leaks in parser` is not a subcommand; it fails as before with "pass the task with -t". To start a task with one of these words, use `-t "memory ..."`. - An unknown option exits 2 with `unknown option --x (see tinyagent --help)`. -- Help: `tinyagent --help`, `-h`, `tinyagent help` or `tinyagent help `. A bare `tinyagent ` with nothing after it prints the same help. For `plugins` and `tinygo` that's always so; for `sandbox`, `memory` and `planner` it's so at an interactive terminal, and with stdin piped or redirected they still run with their flags (so without `-t` they exit 2 with `--task is required`, as before). Plain `tinyagent --help` (or `tinyagent` with no arguments at a terminal; with stdin piped and no arguments it still exits 2 with `--task is required`) shows the usage line and every subcommand (`sandbox`, `memory`, `planner`, `plugins`, `tinygo`) with a one-line description; `tinyagent --help --verbose` lists every flag, grouped into sections (task, model and provider, run control, verify before stop, attempts, review, output and logging, sandbox and permissions, context and tools, memory and planner, plugins and TinyGo, rewind, workflow and eval, setup and info). +- Help: `tinyagent --help`, `-h`, `tinyagent help` or `tinyagent help `. A bare `tinyagent ` with nothing after it prints the same help. For `ratchet`, `plugins` and `tinygo` that's always so; for `sandbox`, `memory` and `planner` it's so at an interactive terminal, and with stdin piped or redirected they still run with their flags (so without `-t` they exit 2 with `--task is required`, as before). Plain `tinyagent --help` (or `tinyagent` with no arguments at a terminal; with stdin piped and no arguments it still exits 2 with `--task is required`) shows the usage line and every subcommand (`sandbox`, `memory`, `ratchet`, `planner`, `plugins`, `tinygo`) with a one-line description; `tinyagent --help --verbose` lists every flag, grouped into sections (task, model and provider, run control, verify before stop, attempts, review, output and logging, sandbox and permissions, context and tools, project rules (ratchet), memory and planner, plugins and TinyGo, rewind, workflow and eval, setup and info). +- `ratchet` records [project rules](ratchet.md) learned from agent failures. - `plugins rollback` and `plugins prune` manage the plugin module version store; see [Plugins](plugins.md#webassembly-plugin-modules). - `tinygo` manages the TinyGo compiler used to build Go plugin modules. **TinyGo is an external dependency, not part of tinyagent:** it is never embedded in the tinyagent binary or repository. tinyagent downloads a pinned TinyGo release on first use (or with `tinygo install`), verifies its SHA-256 checksum and caches it locally; `tinygo update` moves to the version pinned in the current build. @@ -56,6 +59,18 @@ Five subcommands are shortcuts for existing flags. They rewrite the command line | `--memory-approve ` | | Approve one pending project memory entry, or every pending entry, then exit | | `--memory-forget ` | | Delete one project memory entry; `all` deletes every entry and requires `--memory-yes` | | `--memory-path` | | Print the project memory store path for `--cwd`, then exit | +| `--ratchet-add ` | | Record a [project rule](ratchet.md) in `/.tinyagent/ratchet.toml`, then exit. Requires `--ratchet-incident`; without `--ratchet-expires` or `--ratchet-review` it expires in 90 days. Makes no model request and needs no credentials | +| `--ratchet-incident ` | — | With `--ratchet-add`: the failure that led to the rule (required) | +| `--ratchet-expires ` | 90 days from today unless `--ratchet-review` | With `--ratchet-add`: from this date the rule is skipped and flagged as expired | +| `--ratchet-review ` | — | With `--ratchet-add`: when to reconsider the rule; with it and no `--ratchet-expires` the rule has no expiry date | +| `--ratchet-deny-tool ` | by matcher | With `--ratchet-add`: enforce the rule as a permission deny rule for these tools (`\|`-separated globs); alone, denies those tools outright | +| `--ratchet-deny-command ` | — | With `--ratchet-add`: enforce the rule by denying shell commands matching this glob | +| `--ratchet-deny-path ` | — | With `--ratchet-add`: enforce the rule by denying file edits to matching paths (`file_access` syntax) | +| `--ratchet-deny-host ` | — | With `--ratchet-add`: enforce the rule by denying `web_fetch` to this host | +| `--ratchet-list` | | Print the project rules with status (active or expired), incident and expiry or review condition, or a JSON array with `--json`, then exit | +| `--ratchet-path` | | Print the project rules file path for `--cwd`, then exit | +| `--ratchet-remove ` | | Delete the project rule with this id, then exit (exit 1 if there is none) | +| `--no-ratchet` | off | Don't load `.tinyagent/ratchet.toml` for this run: no rules in the prompt and no enforced deny rules | | `--review` | off | Review the uncommitted changes (staged, unstaged and untracked) against `HEAD`: tinyagent collects the diff and runs a read-only review; `-t` adds focus instructions. See [Reviewing changes](runs.md#reviewing-changes) | | `--review-base ` | — | Review what the current branch adds on top of `` (`merge-base..HEAD`), read-only. See [Reviewing changes](runs.md#reviewing-changes) | | `--review-commit ` | — | Review the changes introduced by one commit, read-only. See [Reviewing changes](runs.md#reviewing-changes) | diff --git a/internal/agent/loop.go b/internal/agent/loop.go index ca04bed..fa89479 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -110,6 +110,9 @@ type Agent struct { // Memory is the project memory loaded at run start (memory.enabled); // nil means memory is off. Its section follows AGENTS.md. Memory *ProjectMemory + // Ratchet is the Project rules section (issue #249) placed after the + // project memory section; "" adds nothing. + Ratchet string // NoCompact turns off context compaction: the full history is always // sent, and a context-overflow error ends the run. NoCompact bool @@ -1211,6 +1214,9 @@ func (a *Agent) systemPrompt() string { system += section } system += memorySection(a.Memory) + if a.Ratchet != "" { + system += "\n\n" + a.Ratchet + } system += a.Tools.Skills.Catalog() system += tools.ExtraDirsNotice(effectiveDirs(a.Tools.ExtraDirs, a.Tools.ReadOnly)) if a.Tools.ReadOnly { diff --git a/internal/ratchet/ratchet.go b/internal/ratchet/ratchet.go new file mode 100644 index 0000000..4eaa296 --- /dev/null +++ b/internal/ratchet/ratchet.go @@ -0,0 +1,293 @@ +// Package ratchet stores project rules recorded from agent failures (issue +// #249): each rule keeps the incident that caused it and an expiry date or +// review condition, so stale rules can be pruned as models improve. Rules +// live in /.tinyagent/ratchet.toml; a project without the file pays +// nothing. Active rules become guidance at run start, and a rule with a +// deny matcher is also enforced as a permissions deny rule. +package ratchet + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/BurntSushi/toml" + + "github.com/vicgupta/tinyagent/internal/config" +) + +// RelPath is the rules file relative to --cwd. +const RelPath = ".tinyagent/ratchet.toml" + +// DateLayout is the format of created and expires. +const DateLayout = "2006-01-02" + +// DefaultExpiryDays is how long a rule recorded without --ratchet-expires or +// --ratchet-review stays active. +const DefaultExpiryDays = 90 + +// Default tools a deny matcher applies to when tool is left empty. +const ( + DefaultCommandTools = "bash|start_process" + DefaultPathTools = "write_file|edit_file|apply_patch" + DefaultHostTools = "web_fetch" +) + +// Rule is one recorded rule. +type Rule struct { + // ID is r1, r2, ...; assigned by Add. + ID string `toml:"id"` + // Rule is the guidance shown to the model. + Rule string `toml:"rule"` + // Incident is what went wrong and led to the rule. + Incident string `toml:"incident"` + // Created is the YYYY-MM-DD date the rule was recorded. + Created string `toml:"created"` + // Expires is the YYYY-MM-DD date from which the rule is skipped. + Expires string `toml:"expires,omitempty"` + // Review is a condition under which the rule should be reconsidered. + Review string `toml:"review,omitempty"` + // Deny, when set, also enforces the rule as a permissions deny rule. + Deny *Deny `toml:"deny,omitempty"` +} + +// Deny is an enforceable matcher with the permissions rule syntax. At most +// one of Command, Path and Host may be set. +type Deny struct { + Tool string `toml:"tool,omitempty"` + Command string `toml:"command,omitempty"` + Path string `toml:"path,omitempty"` + Host string `toml:"host,omitempty"` +} + +type file struct { + Rules []Rule `toml:"rule"` +} + +// Path returns the rules file for root. +func Path(root string) string { + return filepath.Join(root, filepath.FromSlash(RelPath)) +} + +// Validate checks a rule's fields. +func (r Rule) Validate() error { + if strings.TrimSpace(r.Rule) == "" { + return errors.New("rule text is empty") + } + if strings.TrimSpace(r.Incident) == "" { + return errors.New("incident is empty") + } + if _, err := time.Parse(DateLayout, r.Created); err != nil { + return fmt.Errorf("created %q is not a YYYY-MM-DD date", r.Created) + } + if r.Expires != "" { + if _, err := time.Parse(DateLayout, r.Expires); err != nil { + return fmt.Errorf("expires %q is not a YYYY-MM-DD date", r.Expires) + } + } + if r.Expires == "" && strings.TrimSpace(r.Review) == "" { + return errors.New("needs an expires date or a review condition") + } + if d := r.Deny; d != nil { + n := 0 + for _, s := range []string{d.Command, d.Path, d.Host} { + if s != "" { + n++ + } + } + if n > 1 { + return errors.New("deny sets more than one of command, path and host") + } + if n == 0 && d.Tool == "" { + return errors.New("deny needs a tool, command, path or host") + } + } + return nil +} + +// Expired reports whether r's expiry date is on or before today. +func (r Rule) Expired(today time.Time) bool { + if r.Expires == "" { + return false + } + exp, err := time.Parse(DateLayout, r.Expires) + if err != nil { + return false + } + y, m, d := today.Date() + return !time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Before(exp) +} + +// Load reads the rules in path. A missing file is no rules and no error. +// Errors name the rule's position. +func Load(path string) ([]Rule, error) { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { + return nil, nil + } + if err != nil { + return nil, err + } + var f file + md, err := toml.Decode(string(b), &f) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if und := md.Undecoded(); len(und) > 0 { + return nil, fmt.Errorf("%s: unknown key %s", path, und[0]) + } + seen := map[string]bool{} + for i, r := range f.Rules { + if r.ID == "" { + return nil, fmt.Errorf("%s: rule %d: id is empty", path, i+1) + } + if seen[r.ID] { + return nil, fmt.Errorf("%s: rule %d: duplicate id %q", path, i+1, r.ID) + } + seen[r.ID] = true + if err := r.Validate(); err != nil { + return nil, fmt.Errorf("%s: rule %s: %w", path, r.ID, err) + } + } + return f.Rules, nil +} + +// Split separates rules into active and expired as of today, keeping order. +func Split(rules []Rule, today time.Time) (active, expired []Rule) { + for _, r := range rules { + if r.Expired(today) { + expired = append(expired, r) + } else { + active = append(active, r) + } + } + return active, expired +} + +// Section renders active rules as a system prompt section, or "" for none. +func Section(active []Rule) string { + if len(active) == 0 { + return "" + } + var b strings.Builder + b.WriteString("## Project rules\n\nThese rules were recorded after earlier agent mistakes in this project (" + RelPath + "). Follow them. Rules marked enforced are also blocked by the permission policy.\n") + for _, r := range active { + fmt.Fprintf(&b, "\n- [%s] %s (incident: %s", r.ID, oneLine(r.Rule), oneLine(r.Incident)) + if r.Deny != nil { + b.WriteString("; enforced") + } + b.WriteString(")") + } + return b.String() +} + +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// PermissionRules returns a deny rule for each active rule with a matcher. +func PermissionRules(active []Rule) []config.PermissionRule { + var out []config.PermissionRule + for _, r := range active { + d := r.Deny + if d == nil { + continue + } + tool := d.Tool + if tool == "" { + switch { + case d.Command != "": + tool = DefaultCommandTools + case d.Path != "": + tool = DefaultPathTools + case d.Host != "": + tool = DefaultHostTools + } + } + out = append(out, config.PermissionRule{ + Tool: tool, + Command: d.Command, + Path: d.Path, + Host: d.Host, + Decision: config.DecisionDeny, + Message: fmt.Sprintf("blocked by project rule %s: %s (incident: %s)", r.ID, oneLine(r.Rule), oneLine(r.Incident)), + }) + } + return out +} + +// Add appends r to the rules in path, creating the file and its directory +// when needed. It assigns the next id and today's created date, and with no +// expires and no review sets expires DefaultExpiryDays from now. +func Add(path string, r Rule, now time.Time) (Rule, error) { + rules, err := Load(path) + if err != nil { + return Rule{}, err + } + r.ID = nextID(rules) + r.Created = now.Format(DateLayout) + if r.Expires == "" && strings.TrimSpace(r.Review) == "" { + r.Expires = now.AddDate(0, 0, DefaultExpiryDays).Format(DateLayout) + } + if err := r.Validate(); err != nil { + return Rule{}, err + } + rules = append(rules, r) + if err := save(path, rules); err != nil { + return Rule{}, err + } + return r, nil +} + +// Remove deletes the rule with id from path. +func Remove(path, id string) error { + rules, err := Load(path) + if err != nil { + return err + } + out := rules[:0] + found := false + for _, r := range rules { + if r.ID == id { + found = true + continue + } + out = append(out, r) + } + if !found { + return fmt.Errorf("no rule with id %q", id) + } + return save(path, out) +} + +func nextID(rules []Rule) string { + n := 0 + for _, r := range rules { + if v, err := strconv.Atoi(strings.TrimPrefix(r.ID, "r")); err == nil && strings.HasPrefix(r.ID, "r") && v > n { + n = v + } + } + return "r" + strconv.Itoa(n+1) +} + +func save(path string, rules []Rule) error { + var buf bytes.Buffer + buf.WriteString("# tinyagent project rules (ratchet). Commit this file; see docs/ratchet.md.\n\n") + if err := toml.NewEncoder(&buf).Encode(file{Rules: rules}); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/ratchet/ratchet_test.go b/internal/ratchet/ratchet_test.go new file mode 100644 index 0000000..3749a50 --- /dev/null +++ b/internal/ratchet/ratchet_test.go @@ -0,0 +1,140 @@ +package ratchet + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var day = time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) + +func TestLoadMissingIsEmpty(t *testing.T) { + rules, err := Load(filepath.Join(t.TempDir(), "nope.toml")) + if err != nil || rules != nil { + t.Fatalf("Load missing = %v, %v; want nil, nil", rules, err) + } +} + +func TestAddLoadRoundTrip(t *testing.T) { + path := Path(t.TempDir()) + r1, err := Add(path, Rule{Rule: "Run go vet before finishing", Incident: "PR #12 broke vet"}, day) + if err != nil { + t.Fatal(err) + } + if r1.ID != "r1" || r1.Created != "2026-10-30" || r1.Expires != "2027-01-28" { + t.Fatalf("r1 = %+v", r1) + } + r2, err := Add(path, Rule{Rule: "Never rm -rf the repo", Incident: "deleted the checkout", Review: "when sandbox is on by default", Deny: &Deny{Command: "rm -rf *"}}, day) + if err != nil { + t.Fatal(err) + } + if r2.ID != "r2" || r2.Expires != "" { + t.Fatalf("r2 = %+v", r2) + } + rules, err := Load(path) + if err != nil { + t.Fatal(err) + } + if len(rules) != 2 || rules[1].Deny == nil || rules[1].Deny.Command != "rm -rf *" || rules[1].Review == "" { + t.Fatalf("loaded %+v", rules) + } + if err := Remove(path, "r1"); err != nil { + t.Fatal(err) + } + r3, err := Add(path, Rule{Rule: "x", Incident: "y", Expires: "2026-12-01"}, day) + if err != nil { + t.Fatal(err) + } + if r3.ID != "r3" { + t.Fatalf("next id after remove = %s, want r3", r3.ID) + } + if err := Remove(path, "r9"); err == nil { + t.Fatal("Remove unknown id: want error") + } +} + +func TestAddValidates(t *testing.T) { + path := Path(t.TempDir()) + for _, r := range []Rule{ + {Incident: "x"}, + {Rule: "x"}, + {Rule: "x", Incident: "y", Expires: "soon"}, + {Rule: "x", Incident: "y", Deny: &Deny{Command: "a", Path: "b"}}, + {Rule: "x", Incident: "y", Deny: &Deny{}}, + } { + if _, err := Add(path, r, day); err == nil { + t.Errorf("Add(%+v): want error", r) + } + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("invalid adds created the file: %v", err) + } +} + +func TestLoadRejectsBadFile(t *testing.T) { + dir := t.TempDir() + for name, body := range map[string]string{ + "syntax": "[[rule]\n", + "unknown": "[[rule]]\nid='r1'\nrule='a'\nincident='b'\ncreated='2026-01-01'\nexpires='2026-02-01'\nbogus=1\n", + "duplicate": "[[rule]]\nid='r1'\nrule='a'\nincident='b'\ncreated='2026-01-01'\nreview='c'\n[[rule]]\nid='r1'\nrule='a'\nincident='b'\ncreated='2026-01-01'\nreview='c'\n", + "noexpiry": "[[rule]]\nid='r1'\nrule='a'\nincident='b'\ncreated='2026-01-01'\n", + } { + p := filepath.Join(dir, name+".toml") + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(p); err == nil { + t.Errorf("%s: want error", name) + } + } +} + +func TestSplitAndSection(t *testing.T) { + rules := []Rule{ + {ID: "r1", Rule: "a", Incident: "i1", Created: "2026-01-01", Expires: "2026-10-30"}, + {ID: "r2", Rule: "b\nmore", Incident: "i2", Created: "2026-01-01", Expires: "2026-10-31"}, + {ID: "r3", Rule: "c", Incident: "i3", Created: "2026-01-01", Review: "later", Deny: &Deny{Command: "git push *"}}, + } + active, expired := Split(rules, day) + if len(expired) != 1 || expired[0].ID != "r1" || len(active) != 2 { + t.Fatalf("active %v expired %v", active, expired) + } + s := Section(active) + if !strings.Contains(s, "[r2] b more (incident: i2)") || !strings.Contains(s, "[r3] c (incident: i3; enforced)") || strings.Contains(s, "r1") { + t.Fatalf("section:\n%s", s) + } + if Section(nil) != "" { + t.Fatal("Section(nil) not empty") + } +} + +func TestPermissionRules(t *testing.T) { + got := PermissionRules([]Rule{ + {ID: "r1", Rule: "a", Incident: "i"}, + {ID: "r2", Rule: "b", Incident: "i", Deny: &Deny{Command: "rm *"}}, + {ID: "r3", Rule: "c", Incident: "i", Deny: &Deny{Path: "go.sum"}}, + {ID: "r4", Rule: "d", Incident: "i", Deny: &Deny{Host: "example.com"}}, + {ID: "r5", Rule: "e", Incident: "i", Deny: &Deny{Tool: "write_file"}}, + }) + if len(got) != 4 { + t.Fatalf("got %d rules", len(got)) + } + want := []string{DefaultCommandTools, DefaultPathTools, DefaultHostTools, "write_file"} + for i, r := range got { + if r.Tool != want[i] || r.Decision != "deny" || !strings.Contains(r.Message, "project rule") { + t.Errorf("rule %d = %+v", i, r) + } + } +} + +func TestLoadUnderAFileIsNoRules(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".tinyagent"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if rules, err := Load(Path(dir)); err != nil || rules != nil { + t.Fatalf("Load = %v, %v", rules, err) + } +}