Documentation
¶
Overview ¶
Package rules — predicate-based rule engine for clawtool. Rules gate operator-defined invariants ("README must be updated when shipping a feature", "no Co-Authored-By in commits", "skill routing-map row required when adding a core tool"). Each rule fires on an event (pre_commit / post_edit / session_end / pre_send) and produces a Result the caller surfaces to the agent or operator.
Why a new package, not BIAM hooks: internal/hooks fires SHELL COMMANDS for every event. This engine is in-process Go evaluation against a structured Context — no shell roundtrip, no JSON encoding to stdin, full type safety on conditions and predicates. The two compose: a hook entry can call `clawtool rules check` to invoke this engine, but most callers (the future Commit tool, the unattended-mode supervisor) should call rules.Evaluate directly.
Design notes:
- Rules are PURE: given a Context, the same rule produces the same Result. No I/O inside Eval; all state is on the Context.
- Conditions are a tiny DSL (changed(glob), commit_message_contains(s), tool_call_count(name) > N) parsed once at load time.
- Severity is a 3-tier ladder (off / warn / block); a "block" result is the caller's signal to refuse the action.
This file declares the public types; eval.go implements the evaluator; loader.go reads .clawtool/rules.toml.
Index ¶
- func AppendRule(path string, r Rule) error
- func CheckRuleAdd(path string, r Rule) error
- func DefaultRoots() []string
- func IsValidEvent(e Event) bool
- func IsValidSeverity(s Severity) bool
- func LocalRulesPath() string
- func RemoveRule(path, name string) (bool, error)
- func RewriteBashCommand(cmd string) string
- func UserRulesPath() string
- type Context
- type Event
- type File
- type Result
- type Rule
- type Severity
- type Verdict
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AppendRule ¶ added in v0.21.4
AppendRule writes one new rule to the file at path, creating the file (and parent dirs) when missing. Pre-flight checks (shape, condition syntax, duplicate name) run via CheckRuleAdd so a malformed add never corrupts the existing rules file. Returns the same error CheckRuleAdd would when the rule wouldn't be appendable.
func CheckRuleAdd ¶ added in v0.22.40
CheckRuleAdd runs every check `AppendRule` would run BEFORE persisting — shape validation, condition-syntax parse, and duplicate-name detection against the existing rules file at path. Returns nil when `AppendRule(path, r)` would succeed.
Used by `clawtool rules new --dry-run` to preview an add without writing the file. AppendRule itself calls this first so the validation logic stays single-source.
func DefaultRoots ¶
func DefaultRoots() []string
DefaultRoots returns the search roots for rules.toml. Project- local (walked up from cwd) takes precedence over user-global, same convention skill / sandbox discovery uses.
func IsValidSeverity ¶
IsValidSeverity is the loader's allowlist guard. Empty severity in TOML defaults to "warn" — most operators want notification, not a hard block, when first wiring a rule.
func LocalRulesPath ¶ added in v0.21.4
func LocalRulesPath() string
LocalRulesPath returns the project-scoped rules path. Prefers an existing `.clawtool/rules.toml` walked up from cwd (so RulesAdd from anywhere inside the project lands in the right file); falls back to creating one in the literal cwd when no ancestor is found (first rule in a fresh project).
func RemoveRule ¶ added in v0.21.4
RemoveRule deletes the named rule from the file at path. Returns ok=false when no rule with that name exists; the file stays untouched.
func RewriteBashCommand ¶ added in v0.22.57
RewriteBashCommand returns cmd with `rtk ` prepended when:
- cmd's first whitespace-delimited token is in the allowlist;
- rtk is on PATH (memoized).
Otherwise cmd is returned unchanged. The function is pure modulo the one-time PATH probe — no I/O on the hot path.
Already-rewritten commands (`rtk git status`) are detected by looking at the first token and returned unchanged.
func UserRulesPath ¶ added in v0.21.4
func UserRulesPath() string
UserRulesPath returns the user-scoped rules path: $XDG_CONFIG_HOME/clawtool/rules.toml (or ~/.config/...).
Types ¶
type Context ¶
type Context struct {
// Event is the lifecycle stage producing the evaluation. A
// rule whose `when` doesn't match Event is skipped without
// being parsed.
Event Event
// ChangedPaths lists the files modified in the current
// session / commit / edit. Forward-slash paths relative to
// the repo root. Backs `changed(glob)` and `any_change(glob)`.
ChangedPaths []string
// CommitMessage is the proposed commit message body (incl.
// trailers). Empty when Event != EventPreCommit. Backs
// `commit_message_contains(s)`.
CommitMessage string
// ToolCalls counts tool invocations in the current session
// keyed by tool name. Backs `tool_call_count(name) > N`.
ToolCalls map[string]int
// Now is injected so tests can pin time. Loader-built
// contexts default to time.Now().
Now time.Time
// Args carries free-form key→string values — escape hatch
// for predicates that don't deserve a typed field yet
// (e.g. SendMessage's target instance for EventPreSend).
// Backs `arg(key) == value`.
Args map[string]string
// DocsyncViolations lists the Go source paths that changed
// in this Context but whose sibling `.md` doc was not also
// updated (per checkpoint.CheckDocsync). The caller
// pre-computes this with internal/checkpoint.CheckDocsync
// BEFORE calling Evaluate — eval.go must stay pure (no
// stat, no I/O), so the FS work happens in the caller.
// Backs `docsync_violation()` (no-arg predicate; true when
// this slice is non-empty). Per ADR-022 §Resolved
// (2026-05-02), the rule type ships as a 3-mode severity
// gradient that reuses Severity verbatim — no parallel
// enum.
DocsyncViolations []string
}
Context is what conditions evaluate against. The caller populates the fields relevant to the firing event; unset fields behave as their zero value (empty slices, zero counts).
Fields are intentionally named to match the predicate vocabulary (e.g. ChangedPaths backs `changed(glob)`, CommitMessage backs `commit_message_contains(s)`).
type Event ¶
type Event string
Event names the lifecycle hook a rule binds to. The set is fixed at v1; new events are additive, never renamed (same stability promise as internal/hooks).
const ( // EventPreCommit fires before the Commit core tool finalises // a commit. Rules here gate message format, file scope, etc. EventPreCommit Event = "pre_commit" // EventPostEdit fires after Edit / Write succeed. Rules here // track "you edited X, now you must edit Y" pairings. EventPostEdit Event = "post_edit" // EventSessionEnd fires when the BIAM task / agent loop // terminates. Last-chance gate: "did you update the README?" EventSessionEnd Event = "session_end" // EventPreSend fires before SendMessage dispatches. Rules // here gate routing (e.g. "code-writing tasks never go to // opencode" — operator's memory feedback, codified). EventPreSend Event = "pre_send" // EventPreUnattended fires when --unattended is about to // activate. Rules here are the safety brake before the // agent loop runs without operator presence. EventPreUnattended Event = "pre_unattended" // EventPreToolUse fires before a tool dispatch (Bash, Read, // etc.) is handed to the underlying tool. Rules here gate // or rewrite tool invocations — e.g. the rtk token-filter // rule prepends `rtk ` to allowlisted Bash commands so the // proxy compresses output before it reaches the agent. EventPreToolUse Event = "pre_tool_use" // EventInterceptorPreToolUse is the upstream MCP RFC spelling // of EventPreToolUse — see SEP-1763 / modelcontextprotocol/ // experimental-ext-interceptors. Accepted as a synonym at // load time; the loader normalizes occurrences to // "pre_tool_use" so Evaluate sees a single canonical event // name and predicates / dispatch keep one code path. EventInterceptorPreToolUse Event = "interceptor:pre_tool_use" // EventPrePush fires before `git push` (or the Commit tool's // Push=true path) sends commits upstream. Rules here gate // what's allowed to leave the local branch — the canonical // example is the `wip-on-protected-branch` rule that blocks // `wip!:` checkpoint commits from reaching main/master/ // develop/release/* (operator's autosquash flow keeps `wip!:` // commits local until they're squashed into a real subject // via `git rebase -i --autosquash`). EventPrePush Event = "pre_push" )
type File ¶
type File struct {
Rule []Rule `toml:"rule"`
}
File is the on-disk shape — the [[rule]] array hosts the actual rules; future top-level metadata (version, comment) goes here.
type Result ¶
type Result struct {
Rule string `json:"rule"`
Severity Severity `json:"severity"`
Passed bool `json:"passed"`
// Reason is the human-readable justification. Empty when
// Passed is true — passing rules are silent.
Reason string `json:"reason,omitempty"`
Hint string `json:"hint,omitempty"`
}
Result is one rule's verdict against one Context.
type Rule ¶
type Rule struct {
Name string `toml:"name"`
Description string `toml:"description,omitempty"`
When Event `toml:"when"`
Condition string `toml:"condition"`
Severity Severity `toml:"severity"`
Hint string `toml:"hint,omitempty"`
// contains filtered or unexported fields
}
Rule is one operator-declared invariant. Loaded from .clawtool/rules.toml and evaluated against a Context at the matching Event.
func Load ¶
Load reads the TOML file at path, validates each rule, and pre-parses each condition so Evaluate doesn't re-parse on every fire.
func LoadDefault ¶
LoadDefault tries each root in DefaultRoots order; returns the first that exists. ok=false when no rules file is configured; callers should treat that as "no rules to enforce" (clawtool's default mode is permissive — rules are opt-in).
func LookupRule ¶ added in v0.22.40
LookupRule reads path and returns the rule named `name` (if present) without modifying the file. Used by `clawtool rules remove --dry-run` to preview the delete: the same lookup RemoveRule does, just without the saveRules write afterwards. ok=false when no rule with that name exists in the file.
Returns (zero Rule, false, err) on parse / read errors so callers can distinguish "rule absent" from "couldn't read".
func ParseBytes ¶
ParseBytes is the test seam — same as Load but takes the body directly. Useful for ad-hoc rule strings in tests.
type Severity ¶
type Severity string
Severity ladders the operator's response to a violation.
const ( // SeverityOff — rule defined but disabled. Useful for // staging a new rule without flipping it on yet. SeverityOff Severity = "off" // SeverityWarn — surface the violation in the result // payload so the agent / operator sees it, but don't block. SeverityWarn Severity = "warn" // SeverityBlock — refuse the action. Callers MUST treat // a block result as a hard stop. SeverityBlock Severity = "block" )
type Verdict ¶
type Verdict struct {
Event Event `json:"event"`
Results []Result `json:"results"`
Warnings []Result `json:"warnings,omitempty"`
Blocked []Result `json:"blocked,omitempty"`
}
Verdict aggregates the result of evaluating every applicable rule against one Context. Callers act on Blocked first (hard stop); Warnings are non-fatal but should be surfaced.
func Evaluate ¶
Evaluate runs every rule whose When matches ctx.Event against the context. Rules are evaluated in declaration order. A condition parse failure surfaces as a Result with Passed=false, Reason naming the parse error, Severity propagated from the rule — otherwise a typo in TOML would silently skip the rule.