diff --git a/AGENTS.md b/AGENTS.md index 512fe65..a282f9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,10 +77,12 @@ and unify at schema time, but at match time they do not reflect input content: that relationship. - **`let` bindings over input-derived values.** `let cmd = tool_input.command` names a path inside the pattern; it does not bind the input's command. -- **Input-dependent `if` clauses inside `when`.** `if list.Contains(flags, "x") - { command: =~"..." }` evaluates the `if` against the pattern's own `flags` - (which is a type, not a value), so the gated fields do not appear - conditionally based on the input. + **Rejected at load time (E0506).** +- **Input-dependent `if`/`for` clauses inside `when`.** `if list.Contains(flags, + "x") { command: =~"..." }` evaluates the `if` against the pattern's own + `flags` (which is a type, not a value), so the gated fields do not appear + conditionally based on the input. `for` comprehensions have the same problem. + **Rejected at load time (E0507).** - **Computed hidden count fields.** `_n: len(flags)` with `_n: >=2` — `flags: [...string]` materialises as `[]` at pattern level, so `_n` is `0` regardless of input. **Rejected at load time (E0508).** Use `list.MatchN` @@ -110,6 +112,11 @@ Express these via: - **Unbound identifiers.** Any ident that resolves to none of a stdlib import binding, a locally-visible hidden sibling (`_foo`), a curated universe builtin, or a bare sibling top-level rule struct. +- **`let` clauses inside `when`.** `let` binds the pattern's type, not the + input's value; the resulting constraint silently misfires. E0506. +- **Comprehensions (`if`/`for`) inside `when`.** Guards and iterators evaluate + against the pattern's own types, not the input's values, so guarded fields + either always or never appear regardless of input. E0507. - **`len()` calls inside `when`.** `len` computes over the pattern's materialised value (`[]` for open lists), not the input's. Use `list.MatchN` instead. E0508. diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..150718f --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,757 @@ +# The fas guide + +This is the long-form companion to the [README](README.md). The README is the +map — terse, every flag in one place. This guide is the walkthrough: it opens +with what fas does and how it feels to author rules, works through the decision +model, and then goes under the hood into how the engine actually matches, +combines, and explains. Read it top to bottom the first time; after that, jump +to the section you need. + +If you just want the command, it's in the README. If you want to understand why +a rule fired — or why it didn't — you're in the right place. + +## Contents + +- [How fas works](#how-fas-works) +- [Your first rule](#your-first-rule) +- [The standard library](#the-standard-library) +- [Writing `when`: patterns, not predicates](#writing-when-patterns-not-predicates) + - [Subsumption is the primitive](#subsumption-is-the-primitive) + - [The parsed view](#the-parsed-view) + - [Structural negation](#structural-negation) + - [What `when` cannot express](#what-when-cannot-express) + - [Binding variables (`@bind`)](#binding-variables-bind) +- [Deciding: the `then` verbs](#deciding-the-then-verbs) +- [How decisions combine](#how-decisions-combine) +- [Layering: global and project](#layering-global-and-project) +- [Organizing rules into packages](#organizing-rules-into-packages) +- [Debugging: explain, vet, diagnostics](#debugging-explain-vet-diagnostics) +- [Wiring into Claude Code](#wiring-into-claude-code) +- [Under the hood](#under-the-hood) + - [Subsumption is the engine](#subsumption-is-the-engine) + - [The preprocessor and the parsed bridge](#the-preprocessor-and-the-parsed-bridge) + - [Closed-world matching](#closed-world-matching) + - [The stdlib as a module overlay](#the-stdlib-as-a-module-overlay) + - [The two-phase pipeline](#the-two-phase-pipeline) + - [Synthesis: one gate, many effects](#synthesis-one-gate-many-effects) + - [Diagnostics: localize then render](#diagnostics-localize-then-render) + - [Fail-open by default](#fail-open-by-default) + - [The catalog as single source of truth](#the-catalog-as-single-source-of-truth) +- [Design motivations](#design-motivations) +- [When something looks wrong](#when-something-looks-wrong) +- [Where to look next](#where-to-look-next) + +## How fas works + +fas reads a structured event on stdin, matches it against rules, and writes one +decision on stdout. The event is an AI coding agent's *hook payload* — a tool +about to run, a prompt submitted, a subagent starting. The decision is one of +five verbs: *allow*, *deny*, *ask*, *inject*, or *modify*. + +The whole engine is one pipeline, and every event runs through it the same way: + +``` +stdin JSON + → adapter.ParseInput (vendor payload → canonical envelope) + → parser.Preprocess (enrich tool_input with a parsed view) + → config.LoadRules (global layer, then project layer) + → pipeline.EvaluatePhases (subsume the input against every when:) + → synthesis.Synthesize (one gate wins; effects accumulate) + → adapter.RenderOutput + → stdout +``` + +The one idea everything else falls out of: a rule's `when` block is a *pattern*, +not a predicate. There is no `$input` variable, no callback, no embedded +scripting. The pattern is the shape of the inputs it matches, and an event +matches when [CUE](https://cuelang.org/) accepts it as an instance of that shape +— plain subsumption. That is why fas has no predicate dialect to learn: the rule +language is CUE itself, and every constraint CUE can check (regex, bounds, list +patterns, disjunctions, optional fields) is a constraint a rule can express. + +A rule has three parts. `when` is the pattern. `then` is the decision to emit on +a match. `meta` is optional bookkeeping. + +```cue +some_rule: { + when: { /* a pattern over the input */ } + then: deny: { rule_id: "...", reason: "...", severity: "HIGH" } + meta: requires: ["..."] // optional +} +``` + +## Your first rule + +Drop a CUE file under `.fas/rules/`. This one blocks a recursive delete of the +home directory: + +```cue +// .fas/rules/no_rm_home.cue +package rules + +import ( + "list" + + "github.com/srnnkls/fas/cue/hook" + "github.com/srnnkls/fas/cue/tool" +) + +no_rm_home: { + when: hook.#PreToolUse & tool.#Bash & { + tool_input: { + command: =~"^rm\\b" + parsed: { + flags: list.MatchN(>0, =~"^-[a-zA-Z]*r[a-zA-Z]*$|^--recursive$") + targets: list.MatchN(>0, =~"^(~|\\$HOME)$") + } + } + } + then: deny: { + rule_id: "no-rm-home" + reason: "Recursive deletion of the home directory is blocked" + severity: "HIGH" + } +} +``` + +Read the `when` as a description: a `PreToolUse` event, for the `Bash` tool, +whose command starts with `rm`, carries a recursive flag, and names `~` or +`$HOME` among its targets. An event matching all of that is denied. + +Before wiring it anywhere, validate it. `fas vet` loads every rule, runs the +package, lint, and schema checks, and prints what it found — no stdin needed: + +```bash +$ fas vet --config .fas/rules +ok: 1 rules loaded (global: 0, project: 1) + project: no-rm-home +``` + +Then run an event through it. `fas eval` reads the hook payload on stdin and +writes the decision on stdout: + +```bash +$ echo '{"hook_event_name":"PreToolUse","tool_name":"Bash", + "tool_input":{"command":"rm -rf ~"},"session_id":"s","cwd":"/tmp"}' \ + | fas eval --config .fas/rules +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Recursive deletion of the home directory is blocked"}} +``` + +A command that doesn't match every conjunct falls through to allow: + +```bash +$ echo '{"hook_event_name":"PreToolUse","tool_name":"Bash", + "tool_input":{"command":"rm -rf ./build"},"session_id":"s","cwd":"/tmp"}' \ + | fas eval --config .fas/rules +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}} +``` + +`./build` is a relative path, so it never matches the `~|$HOME` target +constraint — the rule stays silent and the call is allowed. That asymmetry is the +whole safety story: a rule only ever speaks when its pattern matches, and a +pattern that doesn't match contributes nothing. + +## The standard library + +The `no_rm_home` rule could have spelled out `tool_name: "Bash"` and +`hook_event_name: "PreToolUse"` by hand. It didn't, because fas ships a +*stdlib* — a set of CUE packages that name the vocabulary for you, so a rule +composes pre-built constraints with `&` instead of restating each protocol from +scratch. Import what you need; reference the `#defs`. + +| Package | Import path | What it gives you | +| --- | --- | --- | +| `hook` | `github.com/srnnkls/fas/cue/hook` | per-event shapes: `#PreToolUse`, `#PostToolUse`, `#UserPromptSubmit`, `#Stop`, `#SubagentStart`, `#SubagentStop`, `#Notification` | +| `tool` | `…/cue/tool` | tool identities: `#Bash`, `#Edit`, `#Write`, `#WebFetch`, … and `#Known` (any built-in tool) | +| `agent` | `…/cue/agent` | subagent identities: `#Explore`, `#Plan`, `#GeneralPurpose`, `#Known` | +| `bash` | `…/cue/bash` | the executable inside a Bash call: `#command`, `#subcommand`, `#commandOrRaw` | +| `path` | `…/cue/path` | filesystem patterns: `#hasSystemTarget`, `#hasSystemInCommand`, `#systemTarget`, `#SystemPrefixes` | +| `flag` | `…/cue/flag` | command-line flag shapes: `#hasFlagMatching`, `#hasOption` | +| `action` | `…/cue/action` | destructive semantic verbs: `#hasDestructiveAction` | +| `escalation` | `…/cue/escalation` | privilege-escalation prefixes: `#hasPrivilegeEscalation` | +| `catalog` | `…/cue/catalog` | the raw name tables (`#ToolName`, `#AgentType`, `#EventName`) the layers above are built from | + +Composition reads left to right as a conjunction — *this event, and this tool, +and this property*: + +```cue +when: hook.#PreToolUse & tool.#Bash & (bash.#command & {#name: "tee"}) & path.#hasSystemInCommand +``` + +That matches a `tee` invocation — even behind `sudo tee` or `FOO=1 tee`, because +`bash.#command` reads the *parsed* executable rather than the raw string — whose +command line references a system path like `/etc`. The stdlib matchers prefer +parsed facts over raw-string scans for exactly this reason: they survive the +prefixes (`sudo`, env assignments, leading whitespace) that defeat a naive +`^tee\b`. + +The catalog is the single source of every name. `catalog.#ToolName.Bsh` (a typo) +is an undefined field the loader rejects at load time, not a rule that silently +never matches. The names are Claude Code's identities; a different harness would +ship its own catalog and the layers above it would follow. + +## Writing `when`: patterns, not predicates + +### Subsumption is the primitive + +The evaluator calls `when.Subsume(input)` per rule. Subsumption asks: is the +input an instance of this pattern? It handles every leaf constraint CUE handles, +with no custom operators and no evaluator-level fallback: + +| Want | Write | +| --- | --- | +| literal | `tool_name: "Bash"` | +| bound | `retry_count: <=10`, `tool_name: !="Read"` | +| regex | `command: =~"^rm\\s+-rf"`, `command: !~"^git\\s"` | +| every list element matches | `targets: [...=~"^/etc/"]` | +| at least N elements match | `flags: list.MatchN(>=2, =~"^-")` | +| optional field | `flags?: force?: !=true` | +| struct disjunction | `{tool_name: "Bash"} \| {tool_name: "Write"}` | + +If CUE can check it, `when` can express it. The curated universe builtins `and`, +`or`, `matchN`, `matchIf`, and `len` are also available bare inside `when`. + +### The parsed view + +Matching a raw command string is brittle: `sudo rm`, `FOO=1 rm`, ` rm`, and +`rm` in the second clause of `a && rm -rf /etc` all defeat a `^rm\b` regex. So +before evaluation, the preprocessor parses every Bash command into a structured +*parsed view* and hangs it at `tool_input.parsed`: + +| Field | Holds | +| --- | --- | +| `parsed.commands` | resolved executable names (`rm`, `git`), prefixes stripped | +| `parsed.subcommands` | subcommand tokens (`add`, `commit`) | +| `parsed.targets` | path-like arguments, walked from the AST | +| `parsed.flags` | flag tokens (`-rf`, `--force`) | +| `parsed.actions` | destructive semantic verbs, when present | +| `parsed.calls` | per-command groups: each call's own `command`, `subcommand`, `action`, `targets`, `flags` | +| `parsed.attributes` | side facts: `prefix_commands` (sudo/doas/su), `parse_error` | + +The AST walk reaches every simple command inside `&&`, `||`, `;`, pipelines, and +loops — not just the first token — so a destructive command hidden in a compound +line still surfaces in `parsed`. This is why the stdlib matchers (`bash.#command`, +`path.#hasSystemTarget`, `flag.#hasOption`) read `parsed.*`: they match the +meaning of the command, not its surface syntax. When the parser fails on +malformed input, `bash.#commandOrRaw` falls back to an anchored raw-string scan +so deny coverage survives. + +The flat `parsed.commands`/`parsed.targets` lists are deny-safe but lossy: they +cannot say which target a given command acts on, so `cat README && rm .env` +puts a read verb and a secret in the same lists even though nothing reads the +secret. `parsed.calls` groups each invocation with its own arguments, so a rule +can match a call that *both* runs a read verb and targets a secret: + +```cue +tool_input: parsed: calls: list.MatchN(>0, { + command: _readVerb + targets: list.MatchN(>0, _secretFile) + ... +}) +``` + +(Key on `command` against the verb set you care about — `action` only carries +the destructive-verb table, so read-only verbs like `grep` or `base64` never +populate it.) + +This is not a relational match against the input (see *Sibling references* +below) — the parser pre-joins each command with its arguments at parse time, so +the constraint is an ordinary existential over one struct's fields: "is there a +call whose verb is read and whose own target is a secret." + +### Structural negation + +CUE has no `!` over structs, so push negation down to the leaves — De Morgan by +hand: + +| Wanted | Express as | +| --- | --- | +| `tool_name` is not `"Bash"` | `tool_name: !="Bash"` | +| `command` does not match `^rm` | `command: !~"^rm"` | +| not (`a=1` and `b=2`) | `{a: !=1} \| {b: !=2}` | +| not (match `X` and match `Y`) | `X_negated \| Y_negated` | + +### What `when` cannot express + +Subsumption checks the pattern statically — it does not substitute input +values into the pattern's own references. One genuine limitation follows from +that; the rest are natural-looking CUE idioms that the lint rejects at load +time before they can misfire. + +The limitation: relating two input fields by value. `{command: targets[0], +targets: [...]}` constrains `command` to equal `targets[0]` *within the +pattern*, not within the input — CUE regex is RE2, so backreferences can't +smuggle it in either. Constrain each field on its own observable shape instead: +`tool_input: {command: =~"^rm\\b", parsed: targets: [...=~"^/etc/"]}` matches +an `rm` whose targets are under `/etc` without tying the two together. When you +genuinely need a command bound to *its own* argument (read verb acting on a +secret), use `parsed.calls`, where the parser has already grouped each call +with its arguments — the *parsed view* section shows the pattern. For the rare +case where you genuinely need cross-field equality (e.g. `cwd` equals a +target), the [`@bind` escape hatch](#binding-variables-bind) exists. + +The lint catches four idioms that look right but evaluate against the pattern's +types rather than the input's values: + +- `let` in `when` (E0506). `let cmd = tool_input.command` binds the pattern's + type (`string`), not the input's value. `cmd & =~"^git"` then accepts every + string, not just git commands. Drop the `let` and constrain the path in place: + `tool_input: command: =~"^git\\b"`. +- `if` or `for` in `when` (E0507). `if list.Contains(flags, "--force") { ... }` + evaluates the guard against the pattern's `flags` field — a type like + `[...string]`, not a concrete list — so the gated fields either always or + never appear regardless of input. A `for` comprehension iterates the same + inert type. Since a pattern is already a conjunction, conjoin the fields + directly: + `tool_input: {parsed: flags: list.MatchN(>0, =~"--force"), command: =~"^git push"}`. + For "either of these shapes," use a struct-level disjunction (`{...} | {...}`). +- Computed counts. `_n: len(flags)` with `_n: >=2` materialises `flags` as + `[]` at pattern level, so `_n` is always `0`. CUE catches the static conflict + (`0 & >=2`), so the rule fails to load. Count with a list pattern instead: + `tool_input: parsed: flags: list.MatchN(>=2, =~"^-")`. +- `close` over a `when` struct (E0501). Closing an open hook payload makes the + pattern never subsume an extensible input, so the rule silently never matches. + `close` is excluded from the permitted universe builtins. Constrain the leaf + and leave the struct open: `tool_name: !="Bash"` to exclude a tool. + +### Binding variables (`@bind`) + +Most rules don't need cross-field equality — constrain each field independently +and you're done. For the rare case where two input paths must carry the same +concrete value, `@bind` attributes fill the gap that subsumption leaves. + +Annotate fields in `when` with `@bind(VarName)`. After subsumption passes, a +second pass resolves each annotated path against the input and checks that all +paths sharing a variable name resolved to the same value. If they diverge, the +rule does not fire. + +The motivating case is tying a top-level field to a nested one — something +subsumption structurally cannot do. For example, denying a delete of the +working directory: + +```cue +rm_cwd: { + when: hook.#PreToolUse & tool.#Bash & { + cwd: string @bind(Dir) + tool_input: parsed: targets: [...string] @bind(Dir, 0) + } + then: deny: { + rule_id: "rm-cwd" + reason: "Refusing to delete the working directory" + severity: "HIGH" + } +} +``` + +`@bind(Dir)` on `cwd` captures its value; `@bind(Dir, 0)` on `targets` selects +`targets[0]`. When `cwd` is `/home/user/project` and `targets[0]` is +`/home/user/project`, the binding holds and the rule fires. When `targets[0]` is +`/tmp/junk`, the values diverge and the rule stays silent — even though the +structural pattern matched. + +The sub-path argument selects into the annotated value. `@bind(X, 0)` selects +the first list element; `@bind(X)` with no sub-path captures the whole value. +Two annotations on the same field select different elements — +`@bind(Path, 0) @bind(Path, 1)` checks that a list's first and second elements +are equal. Variable names must start with an uppercase letter. A variable +referenced only once captures but does not constrain. + +When `--explain` is enabled and a binding fails, fas emits an E0601 diagnostic +showing each path and its resolved value. + +## Deciding: the `then` verbs + +A matched rule emits one action. Five verbs, two kinds: + +```cue +then: deny: { rule_id: "...", reason: "...", severity: *"HIGH" | "CRITICAL" | "MEDIUM" | "LOW" } +then: ask: { rule_id: "...", reason: "...", question: "..." } +then: allow: true +then: inject: { rule_id: "...", text: "...", channel: *"agent" | "user", priority: *50 | >=1 & <=100, tags?: [...] } +then: modify: { rule_id: "...", reason: "...", updated_input: _, mode: *"confirm" | "silent", priority: *50 | >=1 & <=100 } +``` + +*Gates* decide whether the call proceeds: `deny` blocks it, `ask` routes it to a +human, `allow` lets it through. Exactly one gate wins per evaluation. + +*Effects* enrich the call without gating it. `inject` adds context — `channel: +"agent"` rides along as `additionalContext`, `channel: "user"` appends to the +decision reason. `modify` rewrites the tool input before it runs. + +A note on `modify` under Claude Code: the `PreToolUse` hook has no +input-rewrite channel, so the `claude` harness rejects any ruleset that emits +`modify` at load time — a configuration error, surfaced by `vet`, not a silent +drop. `modify` is in the schema for harnesses that can honor it. + +`severity` matters when two denies match the same event; it is the first +tiebreak (see [How decisions combine](#how-decisions-combine)). + +## How decisions combine + +A single event can match many rules. Synthesis reduces every match into one +envelope by two independent rules — one for the gate, one for the effects. + +One gate wins. Across all matched gates, kind order is `deny > ask > allow`. +Within `deny`, the tiebreak is severity (`CRITICAL > HIGH > MEDIUM > LOW`) then +`rule_id` ascending; within `ask`, `rule_id` ascending. So a `CRITICAL` deny +beats a `HIGH` deny, and any deny beats any ask. + +Effects accumulate. Every matching `inject` contributes, sorted by priority +descending then `rule_id` ascending, joined by newlines, capped at an 8 KiB +budget. `agent`-channel text becomes `additionalContext`; `user`-channel text is +appended to the reason. A `modify` winner is picked by priority — but dropped if +the final decision is blocking, since a denied call has no input to rewrite. + +The two combine freely. An event can be denied and carry injected context: the +deny gates the call, the inject still rides along in `additionalContext`. An +`inject` on an otherwise-unremarkable call produces an `allow` that carries a +note: + +```bash +$ echo '{"hook_event_name":"PreToolUse","tool_name":"WebFetch", + "tool_input":{"url":"https://x"},"session_id":"s","cwd":"/tmp"}' \ + | fas eval --config .fas/rules +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","additionalContext":"Prefer the local docs cache before fetching the network."}} +``` + +## Layering: global and project + +fas evaluates two rule sets. The *global* layer +(`~/.config/fas/rules/`, override with `--global-config`) is evaluated first; +the *project* layer (`.fas/rules/`, override with `--config`) second. The split +lets a machine-wide baseline live alongside per-repo rules. + +The phases are not independent. A *blocking deny* anywhere in the global layer +short-circuits the pipeline: the project layer is never evaluated, because the +call is already dead. Every other outcome — `ask`, `allow`, and all effects — +lets phase two run, and the two phases' matches concatenate in source order +before synthesis sees them. + +So a global `inject` and a project `deny` both apply (the inject doesn't gate, so +phase two still runs): + +```bash +$ echo '{"hook_event_name":"PreToolUse","tool_name":"Bash", + "tool_input":{"command":"rm -rf ~"},"session_id":"s","cwd":"/tmp"}' \ + | fas eval --global-config ~/.config/fas/rules --config .fas/rules +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Recursive deletion of the home directory is blocked","additionalContext":"Bash call audited by the global policy layer."}} +``` + +But a global `deny` ends it there — the project layer's rules never run: + +```bash +$ echo '{"hook_event_name":"PreToolUse","tool_name":"Bash", + "tool_input":{"command":"git add --force secret.env"},"session_id":"s","cwd":"/tmp"}' \ + | fas eval --global-config ~/.config/fas/rules --config .fas/rules +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"git add --force is blocked by the global policy layer","additionalContext":"Bash call audited by the global policy layer."}} +``` + +## Organizing rules into packages + +A rules directory loads as CUE *packages*, recursively. Each directory of `.cue` +files is one package; subdirectories are separate, independent packages. A tree +can split rules by category and share schema between them: + +``` +.fas/rules/ +├── schema/ +│ └── targets.cue # package schema — reusable #defs +├── security/ +│ ├── git.cue # package rules +│ └── rm.cue # package rules +└── workflow/ + └── commits.cue # package rules +``` + +A few rules govern loading: + +- One package per directory. The `package` clause is optional — files that + omit it adopt the directory's canonical name (the one explicit name declared + there, or `rules`). Two or more different explicit names in one directory is + a load error (`E0505`). Sibling files share scope: a `_helper` or `#Def` in + one file is visible to the others. +- Subdirectories are independent packages. The same rule name may appear in + different packages; a duplicate top-level rule name *within* one package is a + load error (`E0504`). +- Pruned paths. Dotfile dirs (`.x`), underscore dirs (`_x`), and `cue.mod` + are skipped with their subtrees; non-`.cue` files are ignored. +- Total order. Rules load by module-relative directory (lexical), then + filename, then declaration order — independent of traversal order. + +Sharing schema. The rules tree is the CUE module `fas.local/rules`. Put +reusable definitions in a `schema/` package and import it by module-relative +path: + +```cue +// .fas/rules/schema/targets.cue +package schema + +#SystemPath: =~"^(/etc|/sys|/proc)\\b" +``` + +```cue +// .fas/rules/security/git.cue +package rules + +import ( + "github.com/srnnkls/fas/cue/hook" + "github.com/srnnkls/fas/cue/tool" + + "fas.local/rules/schema" +) + +protect_system_paths: { + when: hook.#PreToolUse & tool.#Bash & { + tool_input: parsed: targets: [...schema.#SystemPath] + } + then: deny: { rule_id: "protect-system-paths", reason: "Writes under system paths are blocked", severity: "HIGH" } +} +``` + +Visibility caveat. Only `#defs` cross a package boundary. A `_hidden` field +is package-private — it is not visible to an importing package, and every regular +top-level field is extracted as a *rule*. A shared-defs package must therefore +expose `#defs`, not regular fields. + +## Debugging: explain, vet, diagnostics + +Most rule-authoring time goes to one question: *why didn't my rule fire?* fas +answers it with compiler-style diagnostics rather than a silent non-match. + +`fas vet` is the first line of defense — it validates without any input, so it +drops into CI or a pre-commit hook. It catches package-clause conflicts, +duplicate rule names, the structural lint (cross-rule references, unbound +identifiers), schema violations, and adapter-capability mismatches: + +```bash +$ fas vet --config .fas/rules +ok: 4 rules loaded (global: 0, project: 4) + project: tee-system + project: webfetch-reminder + project: confirm-force-push + project: no-rm-home +``` + +`fas eval --explain` (or `FAS_EXPLAIN=1`) emits diagnostics on stderr explaining +why each rule did or didn't fire — caret frames, `want:`/`got:` labels, error +codes. Three filter modes: `--explain=missed` (the default — only non-firing +rules), `--explain=fired`, and `--explain=both`. + +`fas explain ` runs a single rule against stdin and exits 0 on match, 1 +on no-match (with the diagnostic), 2 on an unknown rule. `fas explain --code +E0201` prints the help text for an error code: + +```bash +$ fas explain --code E0201 +E0201 + +A path segment referenced in the rule does not exist in the input. + +Under closed-world pattern-match semantics, every path referenced in +`when` must exist in the input for the rule to match. Absent paths +cause the rule to silently not fire; the diagnostic shows which +segment broke the chain. +``` + +The error codes you'll meet: + +| Code | Means | +| --- | --- | +| `E0201` | a `when` path is absent from the input (closed-world key miss) | +| `E0301` | a leaf constraint failed (regex, bound) | +| `E0303` | a kind mismatch (string where int wanted) | +| `E0401` | no disjunction arm matched | +| `E0501` | unbound identifier in `when` (load-time) | +| `E0502` | cross-rule reference (load-time) | +| `E0504` / `E0505` | duplicate rule name / conflicting package names (load-time) | +| `E0506` | `let` clause in `when` — binds pattern type, not input value (load-time) | +| `E0507` | `if`/`for` comprehension in `when` — evaluates against pattern, not input (load-time) | +| `E0601` | `@bind` variable mismatch — bound fields resolved to different values | + +Diagnostics render three ways: `--format=text` (default, ANSI-colored, honors +`--color` and `NO_COLOR`), `--format=json` (NDJSON, one object per diagnostic), +and `--format=sarif` (SARIF 2.1.0 for code-scanning tooling). + +For deep debugging, `FAS_LOG=` records one JSON file per invocation — raw +input, preprocessed input, loaded rules, matches, rendered output. These are +written unredacted at mode `0600` and may contain secrets, so point `FAS_LOG` +only at a trusted directory. `FAS_LOG_TTL` (default `1h`) garbage-collects old +logs. + +## Wiring into Claude Code + +fas speaks the Claude Code hook protocol. Register it as a `PreToolUse` hook (and +the other events you want to govern); the harness pipes the JSON event to fas's +stdin and reads the decision from stdout. The default harness is `claude`, so the +flag is optional: + +```bash +fas eval < event.json # --harness claude is the default +``` + +The decision shape is what Claude Code expects: a `hookSpecificOutput` carrying a +`permissionDecision` of `allow`, `deny`, or `ask`, plus an optional +`permissionDecisionReason` and `additionalContext`. Context-only events +(`SubagentStart`, `SubagentStop`) get a decision-free shape carrying just +`additionalContext`. + +## Under the hood + +Everything above is the contract; this is the machinery behind it. None of it is +needed to write a rule, but it is what lets you reason about the edge cases — +and what the engine is actually doing with your event. + +### Subsumption is the engine + +There is no bespoke matcher. `evaluator.Evaluate` compiles each rule's `when` to +a `cue.Value` and calls `when.Subsume(input)` — CUE's own "is this an instance +of that" check. A match is a `nil` error from `Subsume`; a non-match is the error +CUE returns, which is exactly the material the diagnostic layer localizes. The +engine adds ordering, layering, and synthesis around that primitive, but the +matching itself is borrowed wholesale from the language. This is the deliberate +center of the design: no predicate dialect to specify, implement, and keep in +sync with CUE — the rule language and the matcher are the same thing. + +### The preprocessor and the parsed bridge + +The adapter parses the vendor payload into a canonical `envelope.Input`; the +preprocessor then enriches it. For a Bash tool it parses the command into the +`parsed` view (`commands`, `subcommands`, `targets`, `flags`, `actions`, +`attributes`) and repacks it at `tool_input.parsed`, where the stdlib matchers +read it. The parser walks the shell AST, so it sees every simple command in a +compound line — the compound-command coverage isn't a special case in the rules, +it is a property of where `parsed.targets` comes from. The enriched input is +JSON-round-tripped into a `cue.Value`, which is what the evaluator subsumes +against. The lowercase struct tags on the Go side align with the lowercase +field names the CUE schema expects, so the bridge needs no field-name mapping. + +### Closed-world matching + +`when` references are matched against the input under closed-world semantics: +every path the pattern names must exist in the input for the rule to match. An +absent path is not a wildcard — it is a miss, surfaced as `E0201`. This is the +safe direction for a policy engine: a rule that references `signals.user_confirmed` +must not silently match an input that never carried a `signals` field. The +diagnostic names the segment that broke the chain and lists the keys actually +present at that level, so the fix is mechanical. + +### The stdlib as a module overlay + +Rules import the stdlib by its module path +(`github.com/srnnkls/fas/cue/...`) and the rules tree by `fas.local/rules`, yet +neither is fetched from anywhere. The stdlib `.cue` files are embedded into the +binary and mounted into a synthetic CUE module overlay at load time, under a +virtual `cue.mod/pkg/` root. The loader compiles your rules against that overlay, +so imports resolve offline and version-locked to the binary. One visible +consequence: a diagnostic whose constraint originated in the stdlib reports a +provenance path inside that virtual root +(`/__fas_rules__/cue.mod/pkg/github.com/srnnkls/fas/cue/path/path.cue:42:1`) +rather than an on-disk file — the constraint genuinely lives in the embedded +overlay. + +### The two-phase pipeline + +`EvaluatePhases` runs the global layer, then the project layer. The only +short-circuit is a blocking deny in phase one: if any global rule denies, phase +two is skipped and phase one's matches are returned as-is. Every non-blocking +outcome lets phase two run, and the result is the concatenation of both phases' +matches in source order. Diagnostics are pass-through — they only populate when +the explain toggle is on, so the default eval path pays nothing for the +diagnostic machinery. + +### Synthesis: one gate, many effects + +`Synthesize` buckets every match by kind, then reduces. The gate is picked by the +`deny > ask > allow` order with the severity/rule_id tiebreaks. Injects are +cloned, sorted by `(priority DESC, rule_id ASC)`, and concatenated under the size +budget, split by channel into `additionalContext` (agent) and the reason text +(user). A modify winner is the highest-priority modify — but it is dropped when +the final category is blocking, because there is no surviving call to rewrite. +The category is the join of the two: a deny or ask gate sets blocking/asking; a +`confirm`-mode modify with no gate raises asking; everything else allows. + +### Diagnostics: localize then render + +When the explain toggle is on, a non-match runs through `localize`, which walks +the failed conjuncts, classifies each (`KeyMissing`, `BoundViolation`, +`KindMismatch`, `DisjunctionFailed`), and attaches source spans. The renderer +turns that into the Rust-style caret frames you see — and it is deliberately +minimal: no "constraint not satisfied" restatement, a `want:` line only when +the caret doesn't already show the constraint, same-span labels collapsed under +one source line. The same localized data drives all three formats, so JSON and +SARIF carry the full structure (every disjunction arm's span, for instance) even +when the text renderer elides it for readability. + +### Fail-open by default + +An *engine* error — malformed input, a preprocessor failure — does not block the +user. The CLI folds it into an allowing envelope so a buggy hook can never wedge +a workflow. `--fail-closed` flips that to a blocking envelope that names the +underlying error, for production enforcement where a broken policy should stop +the call rather than wave it through. *Rule-loading* errors are always fatal, +though: a misconfigured policy is a configuration bug, surfaced loudly, not an +engine error to swallow. + +### The catalog as single source of truth + +`cue/catalog` holds pure name tables — `#ToolName`, `#AgentType`, `#EventName` — +with no wire-field binding. The `tool`, `agent`, and `hook` packages all +reference the catalog by field, so the member set has exactly one definition. A +typo in a catalog reference (`catalog.#ToolName.Bsh`) is an undefined field the +loader rejects, which is what turns "I misspelled a tool name" from a silent +policy gap into a load-time error. The catalog values are Claude Code's +identities; porting fas to another harness is, at the vocabulary level, shipping +a different catalog. + +## Design motivations + +- CUE, not a predicate DSL. A policy engine needs a constraint language: + regex, bounds, list shapes, disjunction, negation. CUE already is one, with a + formal subsumption check at its core. Borrowing it means there is no dialect to + invent or maintain, and rules get CUE's whole expressive surface for free. + Subsumption *is* matching. +- Patterns over predicates. A predicate (`fn(input) → bool`) is opaque: when + it returns false you get nothing. A pattern is structural, so a non-match can + be *localized* to the conjunct that failed and the input value that broke it. + The diagnostic surface is a direct dividend of choosing patterns. +- Parsed facts over raw strings. Shell syntax is adversarial to regex — + prefixes, compounds, quoting. Parsing once into a structured view and matching + on that makes `sudo rm` and `rm` the same fact, and makes compound-command + coverage fall out of the AST walk instead of needing a rule per shape. +- Fail-open engine, fail-loud config. The two error classes get opposite + defaults on purpose: a runtime hiccup must not block the user (fail-open), but + a broken rule file must not ship silently (fatal). They are different failures + and deserve different dispositions. +- Layering with a single short-circuit. Global-then-project with only a + blocking-deny short-circuit keeps the model predictable: a machine baseline can + hard-stop a call, but everything softer composes, so per-project rules and + global reminders coexist without surprising precedence. + +## When something looks wrong + +- A rule won't fire. Run `fas explain < event.json`. A no-match + prints the failing conjunct with a caret; an absent-path `E0201` means a `when` + path isn't in the input (check the parsed view with `FAS_LOG`). +- A rule fires when it shouldn't. Check the parsed view — `sudo`/compound + forms surface in `parsed`, so a matcher reading raw `command` and one reading + `parsed.commands` can disagree. Prefer the parsed matchers. +- `vet` rejects the tree. Read the code — `E0504`/`E0505` name the offending + files (duplicate rule, conflicting package), `E0501`/`E0502` name the bad + identifier or cross-rule reference, `E0506`/`E0507` flag a `let` or `if`/`for` + inside `when` that would silently misfire. +- A `modify` rule is rejected. The `claude` harness can't honor `modify`; + that's a capability error, not a bug. +- Decisions look merged oddly. Re-read [How decisions combine](#how-decisions-combine): + one gate wins by `deny > ask > allow` then severity; effects accumulate + independently. + +## Where to look next + +- [README](README.md) — the terse reference: every flag, env var, and build task. +- [AGENTS.md](AGENTS.md) — authoring rules in depth, the lint contract, and the + stdlib test invariants. +- `tests/policies.md` — the shipped policy suite, every rule with its deny and + near-miss-allow cases, as runnable scrut blocks. +- `tests/diagnostics.md` — the diagnostic surface, one block per error code and + explain mode. +- `tests/guide.md` — the worked examples from this guide, runnable end to end. diff --git a/internal/config/bind.go b/internal/config/bind.go new file mode 100644 index 0000000..e2e11fc --- /dev/null +++ b/internal/config/bind.go @@ -0,0 +1,160 @@ +package config + +import ( + "fmt" + "strconv" + "strings" + + "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/token" +) + +// Binding records a single @bind(Variable) or @bind(Variable, SubPath) +// attribute found on a field inside a rule's `when` clause. Two fields +// annotated with the same Variable name declare a path-equality constraint: +// the concrete input values at those paths must unify to the same point in +// the lattice. +// +// SubPath is an optional element accessor applied after resolving FieldPath +// against the input — typically a list index ("0", "1", …). An empty +// SubPath means the field's own value is bound directly. +type Binding struct { + Variable string + FieldPath []string + SubPath string + Pos token.Pos +} + +// SubIndex returns the integer list index when SubPath is a non-negative +// integer, and -1 otherwise. +func (b Binding) SubIndex() int { + if b.SubPath == "" { + return -1 + } + n, err := strconv.Atoi(b.SubPath) + if err != nil || n < 0 { + return -1 + } + return n +} + +// extractBindings walks a `when` AST and returns every @bind attribute it +// finds, with the accumulated struct path and optional sub-path argument. +// Returns nil when no bindings are present (the common case). +func extractBindings(whenExpr ast.Expr) ([]Binding, error) { + var bindings []Binding + if err := walkBindings(whenExpr, nil, &bindings); err != nil { + return nil, err + } + return bindings, nil +} + +func walkBindings(expr ast.Expr, path []string, out *[]Binding) error { + switch node := expr.(type) { + case *ast.BinaryExpr: + if err := walkBindings(node.X, path, out); err != nil { + return err + } + return walkBindings(node.Y, path, out) + case *ast.ParenExpr: + return walkBindings(node.X, path, out) + } + st, ok := expr.(*ast.StructLit) + if !ok { + return nil + } + for _, decl := range st.Elts { + f, ok := decl.(*ast.Field) + if !ok { + continue + } + name, _, err := ast.LabelName(f.Label) + if err != nil || name == "" { + continue + } + fieldPath := append(append([]string(nil), path...), name) + + for _, attr := range f.Attrs { + variable, subPath, err := parseBindAttr(attr.Text) + if err != nil { + return fmt.Errorf("field %s: %w", strings.Join(fieldPath, "."), err) + } + if variable == "" { + continue + } + *out = append(*out, Binding{ + Variable: variable, + FieldPath: fieldPath, + SubPath: subPath, + Pos: attr.Pos(), + }) + } + + if inner, ok := f.Value.(*ast.StructLit); ok { + if err := walkBindings(inner, fieldPath, out); err != nil { + return err + } + } + } + return nil +} + +// parseBindAttr parses an attribute text like "@bind(X)" or "@bind(X,0)". +// Returns ("", "", nil) for non-@bind attributes. +func parseBindAttr(text string) (variable, subPath string, err error) { + body, ok := strings.CutPrefix(text, "@bind(") + if !ok { + return "", "", nil + } + body, ok = strings.CutSuffix(body, ")") + if !ok { + return "", "", fmt.Errorf("malformed @bind attribute: %s", text) + } + body = strings.TrimSpace(body) + if body == "" { + return "", "", fmt.Errorf("@bind requires a variable name: %s", text) + } + + parts := strings.SplitN(body, ",", 2) + variable = strings.TrimSpace(parts[0]) + if variable == "" { + return "", "", fmt.Errorf("@bind variable name is empty: %s", text) + } + if !isBindVariable(variable) { + return "", "", fmt.Errorf("@bind variable name %q must be an uppercase letter or identifier: %s", variable, text) + } + if len(parts) == 2 { + subPath = strings.TrimSpace(parts[1]) + if subPath == "" { + return "", "", fmt.Errorf("@bind sub-path is empty: %s", text) + } + } + return variable, subPath, nil +} + +// isBindVariable checks that a variable name is a valid identifier starting +// with an uppercase letter. +func isBindVariable(s string) bool { + if s == "" { + return false + } + first := true + for _, r := range s { + if first { + if r < 'A' || r > 'Z' { + return false + } + first = false + continue + } + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '_': + default: + return false + } + } + return true +} diff --git a/internal/config/bind_test.go b/internal/config/bind_test.go new file mode 100644 index 0000000..02d3991 --- /dev/null +++ b/internal/config/bind_test.go @@ -0,0 +1,220 @@ +package config + +import ( + "testing" + + "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/parser" +) + +func TestParseBindAttr_Valid(t *testing.T) { + tests := []struct { + text string + variable string + subPath string + }{ + {"@bind(X)", "X", ""}, + {"@bind(Cmd)", "Cmd", ""}, + {"@bind(X,0)", "X", "0"}, + {"@bind(X, 0)", "X", "0"}, + {"@bind(Target, 1)", "Target", "1"}, + {"@bind(Val,foo)", "Val", "foo"}, + } + for _, tt := range tests { + t.Run(tt.text, func(t *testing.T) { + v, sp, err := parseBindAttr(tt.text) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != tt.variable { + t.Errorf("variable = %q, want %q", v, tt.variable) + } + if sp != tt.subPath { + t.Errorf("subPath = %q, want %q", sp, tt.subPath) + } + }) + } +} + +func TestParseBindAttr_NonBind(t *testing.T) { + v, sp, err := parseBindAttr("@json(name)") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "" || sp != "" { + t.Errorf("non-@bind attr should return empty; got variable=%q, subPath=%q", v, sp) + } +} + +func TestParseBindAttr_Invalid(t *testing.T) { + tests := []struct { + text string + want string + }{ + {"@bind()", "requires a variable name"}, + {"@bind(x)", "uppercase"}, + {"@bind(123)", "uppercase"}, + {"@bind(X,)", "sub-path is empty"}, + } + for _, tt := range tests { + t.Run(tt.text, func(t *testing.T) { + _, _, err := parseBindAttr(tt.text) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.want) + } + }) + } +} + +func TestExtractBindings_SimpleEquality(t *testing.T) { + src := `{ + tool_input: { + command: string @bind(X) + parsed: { + target: string @bind(X) + } + } + }` + f, err := parser.ParseFile("test.cue", src) + if err != nil { + t.Fatalf("parse: %v", err) + } + // The file has one top-level expression (the struct). + expr := fileExpr(t, f) + + bindings, err := extractBindings(expr) + if err != nil { + t.Fatalf("extractBindings: %v", err) + } + if len(bindings) != 2 { + t.Fatalf("expected 2 bindings, got %d", len(bindings)) + } + + b0 := bindings[0] + if b0.Variable != "X" { + t.Errorf("binding[0].Variable = %q, want %q", b0.Variable, "X") + } + wantPath0 := []string{"tool_input", "command"} + if !pathEqual(b0.FieldPath, wantPath0) { + t.Errorf("binding[0].FieldPath = %v, want %v", b0.FieldPath, wantPath0) + } + + b1 := bindings[1] + if b1.Variable != "X" { + t.Errorf("binding[1].Variable = %q, want %q", b1.Variable, "X") + } + wantPath1 := []string{"tool_input", "parsed", "target"} + if !pathEqual(b1.FieldPath, wantPath1) { + t.Errorf("binding[1].FieldPath = %v, want %v", b1.FieldPath, wantPath1) + } +} + +func TestExtractBindings_WithSubPath(t *testing.T) { + src := `{ + command: string @bind(X) + targets: [...string] @bind(X, 0) + }` + f, err := parser.ParseFile("test.cue", src) + if err != nil { + t.Fatalf("parse: %v", err) + } + expr := fileExpr(t, f) + + bindings, err := extractBindings(expr) + if err != nil { + t.Fatalf("extractBindings: %v", err) + } + if len(bindings) != 2 { + t.Fatalf("expected 2 bindings, got %d", len(bindings)) + } + + if bindings[1].SubPath != "0" { + t.Errorf("binding[1].SubPath = %q, want %q", bindings[1].SubPath, "0") + } + if bindings[1].SubIndex() != 0 { + t.Errorf("binding[1].SubIndex() = %d, want 0", bindings[1].SubIndex()) + } +} + +func TestExtractBindings_NoBindings(t *testing.T) { + src := `{ + tool_name: "Bash" + tool_input: command: =~"^rm" + }` + f, err := parser.ParseFile("test.cue", src) + if err != nil { + t.Fatalf("parse: %v", err) + } + expr := fileExpr(t, f) + + bindings, err := extractBindings(expr) + if err != nil { + t.Fatalf("extractBindings: %v", err) + } + if len(bindings) != 0 { + t.Fatalf("expected 0 bindings, got %d", len(bindings)) + } +} + +func TestExtractBindings_MultipleVariables(t *testing.T) { + src := `{ + a: string @bind(X) + b: string @bind(X) + c: string @bind(Y) + d: string @bind(Y) + }` + f, err := parser.ParseFile("test.cue", src) + if err != nil { + t.Fatalf("parse: %v", err) + } + expr := fileExpr(t, f) + + bindings, err := extractBindings(expr) + if err != nil { + t.Fatalf("extractBindings: %v", err) + } + if len(bindings) != 4 { + t.Fatalf("expected 4 bindings, got %d", len(bindings)) + } + + xCount, yCount := 0, 0 + for _, b := range bindings { + switch b.Variable { + case "X": + xCount++ + case "Y": + yCount++ + } + } + if xCount != 2 { + t.Errorf("X bindings = %d, want 2", xCount) + } + if yCount != 2 { + t.Errorf("Y bindings = %d, want 2", yCount) + } +} + +// fileExpr extracts the struct literal from a parsed CUE file whose top-level +// content is a single struct expression wrapped in an EmbedDecl. +func fileExpr(t *testing.T, f *ast.File) ast.Expr { + t.Helper() + if len(f.Decls) == 1 { + if embed, ok := f.Decls[0].(*ast.EmbedDecl); ok { + return embed.Expr + } + } + t.Fatal("expected a single EmbedDecl wrapping a StructLit") + return nil +} + +func pathEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/config/lint.go b/internal/config/lint.go index 7b95315..8a816ac 100644 --- a/internal/config/lint.go +++ b/internal/config/lint.go @@ -165,9 +165,11 @@ func lintWhen(ruleName string, ruleNames, helperDefNames map[string]struct{}, wh walk(node.Value) return case *ast.LetClause: - walk(node.Expr) + firstErr = letInWhenDiag(ruleName, node) return case *ast.ForClause: + // ForClause is walked only via Comprehension; rejection + // happens at the Comprehension level (E0507). walk(node.Source) return case *ast.Alias: @@ -220,10 +222,7 @@ func lintWhen(ruleName string, ruleNames, helperDefNames map[string]struct{}, wh } return case *ast.Comprehension: - for _, clause := range node.Clauses { - walk(clause) - } - walk(node.Value) + firstErr = comprehensionInWhenDiag(ruleName, node) return case *ast.IfClause: walk(node.Condition) @@ -292,6 +291,34 @@ func checkSelector(ruleName string, ruleNames, helperDefNames map[string]struct{ return checkIdent(ruleName, ruleNames, helperDefNames, rootIdent) } +// predeclaredTypes are CUE predeclared type identifiers that the standalone +// parser does not mark via IsPredeclared() (the sentinel is only set by the +// full compiler pipeline). Without this set they false-positive as E0501. +var predeclaredTypes = map[string]struct{}{ + "_": {}, + "string": {}, + "bytes": {}, + "bool": {}, + "int": {}, + "float": {}, + "number": {}, + "null": {}, + "uint": {}, + "uint8": {}, + "uint16": {}, + "uint32": {}, + "uint64": {}, + "uint128": {}, + "int8": {}, + "int16": {}, + "int32": {}, + "int64": {}, + "int128": {}, + "float32": {}, + "float64": {}, + "rune": {}, +} + // permittedUniverseBuiltins are CUE universe functions allowed bare in `when`. // The parser never binds universe builtins to an ast.Node and IsPredeclared() // recognises only type/range names, so absent this set they false-positive as @@ -329,11 +356,12 @@ func checkIdent(ruleName string, ruleNames, helperDefNames map[string]struct{}, // will see a struct value and unify normally. return nil } - // Predeclared identifiers (string, int, bool, etc.) and top-level builtins - // have IsPredeclared() true. Let them through. if id.IsPredeclared() { return nil } + if _, ok := predeclaredTypes[name]; ok { + return nil + } if _, ok := permittedUniverseBuiltins[name]; ok { return nil } @@ -439,6 +467,53 @@ func lenInWhenDiag(ruleName string, call *ast.CallExpr) error { return diag.NewDiagError(d, nil, nil) } +// letInWhenDiag builds an E0506 DiagError for a `let` clause inside `when`. +func letInWhenDiag(ruleName string, lc *ast.LetClause) error { + d := diag.Diagnostic{ + Code: diag.E0506.Code, + Severity: diag.SeverityError, + Title: "rule " + quote(ruleName) + + ": `let` clause in `when` binds the pattern type, not the input value", + Primary: diag.Label{ + Pos: lc.Pos(), + Len: 3, // "let" + Msg: "`let` in `when` of rule " + quote(ruleName), + }, + Help: diag.E0506.Help, + } + return diag.NewDiagError(d, nil, nil) +} + +// comprehensionInWhenDiag builds an E0507 DiagError for an `if` or `for` +// comprehension inside `when`. +func comprehensionInWhenDiag(ruleName string, comp *ast.Comprehension) error { + kind := "comprehension" + kwLen := 3 + if len(comp.Clauses) > 0 { + switch comp.Clauses[0].(type) { + case *ast.IfClause: + kind = "`if` guard" + kwLen = 2 + case *ast.ForClause: + kind = "`for` loop" + kwLen = 3 + } + } + d := diag.Diagnostic{ + Code: diag.E0507.Code, + Severity: diag.SeverityError, + Title: "rule " + quote(ruleName) + + ": " + kind + " in `when` evaluates against the pattern, not the input", + Primary: diag.Label{ + Pos: comp.Pos(), + Len: kwLen, + Msg: kind + " in `when` of rule " + quote(ruleName), + }, + Help: diag.E0507.Help, + } + return diag.NewDiagError(d, nil, nil) +} + // quote wraps s in double quotes without escaping; identifier and rule names // never contain quote-sensitive characters, so strconv.Quote would only add // noise to the rendered diagnostic. diff --git a/internal/config/lint_diag_test.go b/internal/config/lint_diag_test.go index 5b636f4..676e6b6 100644 --- a/internal/config/lint_diag_test.go +++ b/internal/config/lint_diag_test.go @@ -532,3 +532,129 @@ len_rule: { t.Errorf("primary span should anchor at `len` keyword; got %q", got) } } + +// TestLoadRules_LintDiag_LetInWhen_EmitsE0506 pins that a `let` clause inside +// `when` surfaces as E0506 with the primary span anchored at the `let` keyword. +func TestLoadRules_LintDiag_LetInWhen_EmitsE0506(t *testing.T) { + const src = `package rules + +let_rule: { + when: { + let cmd = tool_input.command + tool_input: command: string + _check: cmd & =~"^git" + } + then: deny: { + rule_id: "l" + reason: "nope" + } +} +` + path := lintFixture(t, "let_when", src) + dir := filepath.Dir(path) + + _, err := config.LoadRules(dir) + if err == nil { + t.Fatal("expected let in when to be rejected, got nil error") + } + + de, ok := recoverDiag(t, err) + if !ok { + t.Fatalf("expected err to carry *diag.DiagError via errors.As; got: %v", err) + } + if de.D.Code != "E0506" { + t.Errorf("diagnostic Code = %q, want %q", de.D.Code, "E0506") + } + if got := tokenAtPos(t, de.D.Primary.Pos, 3); got != "let" { + t.Errorf("primary span should anchor at `let` keyword; got %q", got) + } + if de.D.Help == "" { + t.Errorf("E0506 diagnostic should carry a Help string; got empty") + } +} + +// TestLoadRules_LintDiag_IfInWhen_EmitsE0507 pins that an `if` comprehension +// inside `when` surfaces as E0507 with the primary span anchored at the `if` +// keyword. +func TestLoadRules_LintDiag_IfInWhen_EmitsE0507(t *testing.T) { + const src = `package rules + +import "list" + +if_rule: { + when: { + tool_input: parsed: { + flags: [...string] + if list.Contains(flags, "--force") { + commands: [...=~"^git$"] + } + } + } + then: deny: { + rule_id: "i" + reason: "nope" + } +} +` + path := lintFixture(t, "if_when", src) + dir := filepath.Dir(path) + + _, err := config.LoadRules(dir) + if err == nil { + t.Fatal("expected if comprehension in when to be rejected, got nil error") + } + + de, ok := recoverDiag(t, err) + if !ok { + t.Fatalf("expected err to carry *diag.DiagError via errors.As; got: %v", err) + } + if de.D.Code != "E0507" { + t.Errorf("diagnostic Code = %q, want %q", de.D.Code, "E0507") + } + if got := tokenAtPos(t, de.D.Primary.Pos, 2); got != "if" { + t.Errorf("primary span should anchor at `if` keyword; got %q", got) + } + if !strings.Contains(de.D.Title, "`if` guard") { + t.Errorf("E0507 title should mention `if` guard; got %q", de.D.Title) + } +} + +// TestLoadRules_LintDiag_ForInWhen_EmitsE0507 pins that a `for` comprehension +// inside `when` surfaces as E0507. +func TestLoadRules_LintDiag_ForInWhen_EmitsE0507(t *testing.T) { + const src = `package rules + +for_rule: { + when: { + tool_input: parsed: { + flags: [...string] + for f in flags { + (f): true + } + } + } + then: deny: { + rule_id: "f" + reason: "nope" + } +} +` + path := lintFixture(t, "for_when", src) + dir := filepath.Dir(path) + + _, err := config.LoadRules(dir) + if err == nil { + t.Fatal("expected for comprehension in when to be rejected, got nil error") + } + + de, ok := recoverDiag(t, err) + if !ok { + t.Fatalf("expected err to carry *diag.DiagError via errors.As; got: %v", err) + } + if de.D.Code != "E0507" { + t.Errorf("diagnostic Code = %q, want %q", de.D.Code, "E0507") + } + if !strings.Contains(de.D.Title, "`for` loop") { + t.Errorf("E0507 title should mention `for` loop; got %q", de.D.Title) + } +} diff --git a/internal/config/loader.go b/internal/config/loader.go index c80aae5..91a5df0 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -74,6 +74,11 @@ type Rule struct { WhenMap map[string]any `json:",omitempty"` Then *Action Meta *Meta + // Bindings holds @bind variable annotations extracted from the when AST. + // Nil for rules without bindings (the common case). The evaluator checks + // bindings post-Subsume: all fields sharing a variable name must resolve + // to equal concrete values in the input. + Bindings []Binding `json:",omitempty"` } // RulesModuleRoot is the synthetic module-root directory used by the loader. @@ -842,6 +847,11 @@ func decodeRule(v cue.Value, fieldVal cue.Value) (Rule, error) { if original := fieldVal.LookupPath(cue.ParsePath("when")); original.Exists() { if expr, ok := whenSyntax(original); ok { out.WhenSyntax = expr + bindings, err := extractBindings(expr) + if err != nil { + return Rule{}, fmt.Errorf("extract @bind: %w", err) + } + out.Bindings = bindings } } // Best-effort debug map. Non-concrete constraints (e.g. regex diff --git a/internal/diag/codes.go b/internal/diag/codes.go index b83efbb..82f49df 100644 --- a/internal/diag/codes.go +++ b/internal/diag/codes.go @@ -194,6 +194,29 @@ different packages the merge is ambiguous; rename them to one shared package, or split the divergent files into their own directory.`, } +var E0506 = CodeInfo{ + Code: "E0506", + Help: `A ` + "`let`" + ` clause inside ` + "`when`" + ` binds the pattern's type, not the input's value. + +` + "`let cmd = tool_input.command`" + ` names the ` + "`command`" + ` path inside the pattern, +which is a type constraint (e.g. ` + "`string`" + `), not the concrete value from +the input. A downstream constraint like ` + "`cmd & =~\"^git\"`" + ` then matches +every input whose command is a string, not just git commands. Remove +the ` + "`let`" + ` and place the constraint directly on the field.`, +} + +var E0507 = CodeInfo{ + Code: "E0507", + Help: `A comprehension (` + "`if`" + ` or ` + "`for`" + `) inside ` + "`when`" + ` evaluates against the pattern, not the input. + +` + "`if list.Contains(flags, \"--force\") { ... }`" + ` evaluates the guard against +the pattern's own ` + "`flags`" + ` field, which is a type (e.g. ` + "`[...string]`" + `), +not a concrete list from the input. The guarded fields either always +appear or never appear, regardless of what the input carries. Remove +the comprehension and express the constraint directly as a field +pattern (e.g. ` + "`flags: list.MatchN(>0, =~\"--force\")`" + `).`, +} + var E0508 = CodeInfo{ Code: "E0508", Help: "`len` inside `" + `when` + "` computes over the pattern's materialised value, not the input's." + ` @@ -205,9 +228,22 @@ it cannot react to the input's actual list length. Use ` + "`list.MatchN`" + ` instead: ` + "`flags: list.MatchN(>=2, string)`" + `.`, } +// E06xx — lattice binding. + +var E0601 = CodeInfo{ + Code: "E0601", + Help: `Fields annotated with the same @bind variable resolved to different values. + +Two or more fields in ` + "`when`" + ` carry ` + "`@bind(X)`" + ` with the same variable +name. At match time, the concrete input values at those paths must be +equal (they must unify to the same point in the lattice). This diagnostic +fires when the input structurally matched the pattern but the bound values +diverged — e.g. ` + "`command`" + ` was ` + "`\"cat\"`" + ` while ` + "`targets[0]`" + ` was ` + "`\"dog\"`" + `.`, +} + // CodesInScopeV1 freezes the code count for this scope; bumping it requires // a deliberate design review to justify adding a new code. -const CodesInScopeV1 = 18 +const CodesInScopeV1 = 21 // codeRegistry maps each stable code string to its CodeInfo. // Built at package init so that duplicate codes fail loudly rather @@ -217,7 +253,8 @@ var codeRegistry = buildCodeRegistry( E0201, E0202, E0203, E0301, E0302, E0303, E0304, E0401, E0402, - E0501, E0502, E0503, E0504, E0505, E0508, + E0501, E0502, E0503, E0504, E0505, E0506, E0507, E0508, + E0601, ) func buildCodeRegistry(entries ...CodeInfo) map[string]CodeInfo { diff --git a/internal/diag/codes_e0504_test.go b/internal/diag/codes_e0504_test.go index 1ad006a..5d7f9ab 100644 --- a/internal/diag/codes_e0504_test.go +++ b/internal/diag/codes_e0504_test.go @@ -21,7 +21,7 @@ func TestE0504_Registered(t *testing.T) { // TestE0504_BumpsCodesInScope pins that registering E0504 advances the frozen // in-scope code count from 16 to 17 (E0505 is already counted). func TestE0504_BumpsCodesInScope(t *testing.T) { - if CodesInScopeV1 != 18 { - t.Errorf("CodesInScopeV1 = %d, want 18 after E0504 joins the registry", CodesInScopeV1) + if CodesInScopeV1 != 21 { + t.Errorf("CodesInScopeV1 = %d, want 21 after E0504 joins the registry", CodesInScopeV1) } } diff --git a/internal/diag/codes_e0505_test.go b/internal/diag/codes_e0505_test.go index a7b0cb9..9f743b7 100644 --- a/internal/diag/codes_e0505_test.go +++ b/internal/diag/codes_e0505_test.go @@ -22,7 +22,7 @@ func TestDiag_E0505_Registered(t *testing.T) { // TestDiag_CodesInScopeV1_IncludesE0505 pins the frozen code count once both // E0505 (CRP-001) and E0504 (CRP-005) are registered: 15 -> 17. func TestDiag_CodesInScopeV1_IncludesE0505(t *testing.T) { - if CodesInScopeV1 != 18 { - t.Errorf("CodesInScopeV1 = %d, want 18 (E0504 + E0505 registered)", CodesInScopeV1) + if CodesInScopeV1 != 21 { + t.Errorf("CodesInScopeV1 = %d, want 21 (E0504 + E0505 registered)", CodesInScopeV1) } } diff --git a/internal/diag/codes_test.go b/internal/diag/codes_test.go index a055c94..669369e 100644 --- a/internal/diag/codes_test.go +++ b/internal/diag/codes_test.go @@ -41,7 +41,11 @@ func expectedCodes() []expectedCode { {"E0503", "scope/binding", func() diag.CodeInfo { return diag.E0503 }}, {"E0504", "scope/binding", func() diag.CodeInfo { return diag.E0504 }}, {"E0505", "scope/binding", func() diag.CodeInfo { return diag.E0505 }}, + {"E0506", "scope/binding", func() diag.CodeInfo { return diag.E0506 }}, + {"E0507", "scope/binding", func() diag.CodeInfo { return diag.E0507 }}, {"E0508", "scope/binding", func() diag.CodeInfo { return diag.E0508 }}, + // E06xx — lattice binding + {"E0601", "lattice binding", func() diag.CodeInfo { return diag.E0601 }}, } } @@ -74,7 +78,7 @@ func TestCodeStringMatchesVariableName(t *testing.T) { // Codes fall within the documented ranges defined by the design. // E01xx → rule load, E02xx → path resolution, etc. func TestCodesFallWithinDocumentedRanges(t *testing.T) { - pattern := regexp.MustCompile(`^E(0[1-5])\d{2}$`) + pattern := regexp.MustCompile(`^E(0[1-6])\d{2}$`) for _, ec := range expectedCodes() { t.Run(ec.code, func(t *testing.T) { if !pattern.MatchString(ec.code) { @@ -185,7 +189,7 @@ func TestCodeInfoZeroValue(t *testing.T) { // Asserts that this scope adds no new error codes. func TestNoNewCodesInScope(t *testing.T) { - const frozen = 18 + const frozen = 21 if got := diag.CodesInScopeV1; got != frozen { t.Errorf("diag.CodesInScopeV1 = %d, want %d", got, frozen) } diff --git a/internal/diag/reason.go b/internal/diag/reason.go index 85f611d..9bdcc68 100644 --- a/internal/diag/reason.go +++ b/internal/diag/reason.go @@ -76,6 +76,17 @@ type KeyMissing struct { func (KeyMissing) reason() {} +// BindingMismatch reports that two or more fields annotated with the same +// @bind variable resolved to different concrete values in the input. The +// lattice requires all paths sharing a variable to unify at the same point. +type BindingMismatch struct { + Variable string + Paths []string + Values []string +} + +func (BindingMismatch) reason() {} + // Provenance is a metadata Reason surfacing where a constraint was // introduced, used on footer labels to show cross-file origin. type Provenance struct { diff --git a/internal/diag/render_reason.go b/internal/diag/render_reason.go index 80677e8..0927191 100644 --- a/internal/diag/render_reason.go +++ b/internal/diag/render_reason.go @@ -79,6 +79,8 @@ func renderReasonText(r Reason, labelMsg string) reasonRender { return renderDisjunctionFailed(v, labelMsg) case KeyMissing: return renderKeyMissing(v) + case BindingMismatch: + return renderBindingMismatch(v) case Provenance: return renderProvenance(v) } @@ -262,6 +264,21 @@ func renderKeyMissing(v KeyMissing) reasonRender { return out } +// renderBindingMismatch emits a primary message naming the variable and +// a footer listing each path with its resolved value. +func renderBindingMismatch(v BindingMismatch) reasonRender { + msg := fmt.Sprintf("@bind(%s): values differ", v.Variable) + var footers []string + for i, p := range v.Paths { + val := "" + if i < len(v.Values) { + val = v.Values[i] + } + footers = append(footers, fmt.Sprintf("= note: %s = %s", p, val)) + } + return reasonRender{msg: msg, footers: footers} +} + // renderProvenance emits the cross-file origin footer. Invalid spans drop // silently (NF3). func renderProvenance(v Provenance) reasonRender { diff --git a/internal/evaluator/bind.go b/internal/evaluator/bind.go new file mode 100644 index 0000000..1f0759c --- /dev/null +++ b/internal/evaluator/bind.go @@ -0,0 +1,180 @@ +package evaluator + +import ( + "cuelang.org/go/cue" + + "github.com/srnnkls/fas/internal/config" + "github.com/srnnkls/fas/internal/diag" +) + +// bindingFailure describes a single variable whose bound paths resolved to +// different concrete values in the input. +type bindingFailure struct { + variable string + paths []string + values []string +} + +// checkBindings resolves each @bind variable group against the input and +// verifies that all paths sharing a variable name unify to the same concrete +// value. Returns nil when all bindings are satisfied or when the rule has no +// bindings. Returns the first failing variable group otherwise. +func checkBindings(bindings []config.Binding, input cue.Value) *bindingFailure { + if len(bindings) == 0 { + return nil + } + + groups := groupBindings(bindings) + for _, g := range groups { + if len(g.bindings) < 2 { + continue + } + var resolved []cue.Value + var paths []string + for _, b := range g.bindings { + v := resolvePath(input, b) + if !v.Exists() { + return &bindingFailure{ + variable: g.variable, + paths: bindingPaths(g.bindings), + values: []string{""}, + } + } + resolved = append(resolved, v) + paths = append(paths, formatBindingPath(b)) + } + + if !allEqual(resolved) { + values := make([]string, len(resolved)) + for i, v := range resolved { + values[i] = renderValue(v) + } + return &bindingFailure{ + variable: g.variable, + paths: paths, + values: values, + } + } + } + return nil +} + +// bindingGroup collects all bindings that share a variable name. +type bindingGroup struct { + variable string + bindings []config.Binding +} + +// groupBindings partitions bindings by variable name, preserving order +// of first occurrence. +func groupBindings(bindings []config.Binding) []bindingGroup { + idx := map[string]int{} + var groups []bindingGroup + for _, b := range bindings { + if i, ok := idx[b.Variable]; ok { + groups[i].bindings = append(groups[i].bindings, b) + } else { + idx[b.Variable] = len(groups) + groups = append(groups, bindingGroup{ + variable: b.Variable, + bindings: []config.Binding{b}, + }) + } + } + return groups +} + +// resolvePath looks up a binding's path in the input, optionally applying +// the sub-path (e.g., list index) to reach a nested element. +func resolvePath(input cue.Value, b config.Binding) cue.Value { + selectors := make([]cue.Selector, len(b.FieldPath)) + for i, seg := range b.FieldPath { + selectors[i] = cue.Str(seg) + } + v := input.LookupPath(cue.MakePath(selectors...)) + if !v.Exists() { + return v + } + if idx := b.SubIndex(); idx >= 0 { + v = v.LookupPath(cue.MakePath(cue.Index(idx))) + } + return v +} + +// allEqual returns true when every value in vs is pairwise equal under +// mutual subsumption. An empty or single-element slice is trivially equal. +func allEqual(vs []cue.Value) bool { + if len(vs) < 2 { + return true + } + first := vs[0] + for _, v := range vs[1:] { + if first.Subsume(v, cue.Final()) != nil || v.Subsume(first, cue.Final()) != nil { + return false + } + } + return true +} + +// formatBindingPath renders a binding's full resolution path for diagnostics. +func formatBindingPath(b config.Binding) string { + path := joinDotPath(b.FieldPath) + if idx := b.SubIndex(); idx >= 0 { + return path + "[" + b.SubPath + "]" + } + if b.SubPath != "" { + return path + "." + b.SubPath + } + return path +} + +func joinDotPath(parts []string) string { + if len(parts) == 0 { + return "" + } + result := parts[0] + for _, p := range parts[1:] { + result += "." + p + } + return result +} + +func bindingPaths(bindings []config.Binding) []string { + paths := make([]string, len(bindings)) + for i, b := range bindings { + paths[i] = formatBindingPath(b) + } + return paths +} + +// bindingDiagnostic constructs an E0601 diagnostic from a binding failure. +func bindingDiagnostic(rule config.Rule, bf *bindingFailure) diag.Diagnostic { + pos := rule.When.Pos() + if rule.WhenSyntax != nil { + pos = rule.WhenSyntax.Pos() + } + for _, b := range rule.Bindings { + if b.Variable == bf.variable && b.Pos.IsValid() { + pos = b.Pos + break + } + } + return diag.Diagnostic{ + Code: diag.E0601.Code, + Severity: diag.SeverityError, + Title: "binding variable mismatch", + Primary: diag.Label{ + Pos: pos, + Len: len("@bind(" + bf.variable + ")"), + Msg: "@bind(" + bf.variable + "): values differ", + Reasons: []diag.Reason{ + diag.BindingMismatch{ + Variable: bf.variable, + Paths: bf.paths, + Values: bf.values, + }, + }, + }, + Help: diag.E0601.Help, + } +} diff --git a/internal/evaluator/bind_test.go b/internal/evaluator/bind_test.go new file mode 100644 index 0000000..2f4fd6b --- /dev/null +++ b/internal/evaluator/bind_test.go @@ -0,0 +1,350 @@ +package evaluator_test + +import ( + "testing" + + "cuelang.org/go/cue/cuecontext" + + "github.com/srnnkls/fas/internal/evaluator" +) + +// TestEvaluate_Bind_EqualFieldsMatch verifies that two fields annotated with +// the same @bind variable match when the input carries equal values at both +// paths. +func TestEvaluate_Bind_EqualFieldsMatch(t *testing.T) { + dir := t.TempDir() + mustWriteRule(t, dir, "bind_eq.cue", `{ + when: { + a: string @bind(X) + b: string @bind(X) + } + then: deny: {rule_id: "bind-eq", reason: "a equals b"} + }`) + rules := loadRules(t, dir) + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + if len(rules[0].Bindings) != 2 { + t.Fatalf("expected 2 bindings, got %d", len(rules[0].Bindings)) + } + + ctx := cuecontext.New() + + t.Run("equal values match", func(t *testing.T) { + input := mustCompile(t, ctx, `{a: "hello", b: "hello"}`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 match, got %d", len(got)) + } + if got[0].Action.RuleID != "bind-eq" { + t.Fatalf("expected rule_id=bind-eq, got %q", got[0].Action.RuleID) + } + }) + + t.Run("different values do not match", func(t *testing.T) { + input := mustCompile(t, ctx, `{a: "hello", b: "world"}`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 matches, got %d", len(got)) + } + }) +} + +// TestEvaluate_Bind_SubIndex verifies that @bind with a sub-path index +// resolves to the correct list element. +func TestEvaluate_Bind_SubIndex(t *testing.T) { + dir := t.TempDir() + mustWriteRule(t, dir, "bind_idx.cue", `{ + when: { + tool_input: { + command: string @bind(X) + parsed: targets: [...string] @bind(X, 0) + } + } + then: deny: {rule_id: "bind-idx", reason: "command equals first target"} + }`) + rules := loadRules(t, dir) + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + if len(rules[0].Bindings) != 2 { + t.Fatalf("expected 2 bindings, got %d", len(rules[0].Bindings)) + } + + ctx := cuecontext.New() + + t.Run("command equals targets[0]", func(t *testing.T) { + input := mustCompile(t, ctx, `{ + tool_input: { + command: "cat" + parsed: targets: ["cat", "/etc/passwd"] + } + }`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 match, got %d", len(got)) + } + }) + + t.Run("command differs from targets[0]", func(t *testing.T) { + input := mustCompile(t, ctx, `{ + tool_input: { + command: "rm" + parsed: targets: ["cat", "/etc/passwd"] + } + }`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 matches, got %d", len(got)) + } + }) +} + +// TestEvaluate_Bind_StaticConstraintStillApplies confirms that Subsume checks +// the static part of the pattern (e.g., string type constraint) before +// bindings are evaluated. If the static part doesn't match, the binding +// check is never reached. +func TestEvaluate_Bind_StaticConstraintStillApplies(t *testing.T) { + dir := t.TempDir() + mustWriteRule(t, dir, "bind_static.cue", `{ + when: { + a: =~"^hello" @bind(X) + b: string @bind(X) + } + then: deny: {rule_id: "bind-static", reason: "a starts with hello and equals b"} + }`) + rules := loadRules(t, dir) + + ctx := cuecontext.New() + + t.Run("both static and binding satisfied", func(t *testing.T) { + input := mustCompile(t, ctx, `{a: "hello world", b: "hello world"}`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 match, got %d", len(got)) + } + }) + + t.Run("static fails (regex), binding would pass", func(t *testing.T) { + input := mustCompile(t, ctx, `{a: "goodbye", b: "goodbye"}`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 matches (regex fails), got %d", len(got)) + } + }) + + t.Run("static passes, binding fails", func(t *testing.T) { + input := mustCompile(t, ctx, `{a: "hello world", b: "hello earth"}`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 matches (binding fails), got %d", len(got)) + } + }) +} + +// TestEvaluate_Bind_SingleVariable_NoConstraint confirms that a variable +// referenced only once adds no constraint — it's a capture, not an equality. +func TestEvaluate_Bind_SingleVariable_NoConstraint(t *testing.T) { + dir := t.TempDir() + mustWriteRule(t, dir, "bind_single.cue", `{ + when: { + a: string @bind(X) + } + then: deny: {rule_id: "bind-single", reason: "just a capture"} + }`) + rules := loadRules(t, dir) + + ctx := cuecontext.New() + input := mustCompile(t, ctx, `{a: "anything"}`) + + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 1 { + t.Fatalf("single-variable binding should not constrain; expected 1 match, got %d", len(got)) + } +} + +// TestEvaluate_Bind_MultipleVariables confirms that multiple independent +// variable groups are checked independently. +func TestEvaluate_Bind_MultipleVariables(t *testing.T) { + dir := t.TempDir() + mustWriteRule(t, dir, "bind_multi.cue", `{ + when: { + a: string @bind(X) + b: string @bind(X) + c: string @bind(Y) + d: string @bind(Y) + } + then: deny: {rule_id: "bind-multi", reason: "two pairs"} + }`) + rules := loadRules(t, dir) + + ctx := cuecontext.New() + + t.Run("both pairs equal", func(t *testing.T) { + input := mustCompile(t, ctx, `{a: "foo", b: "foo", c: "bar", d: "bar"}`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 match, got %d", len(got)) + } + }) + + t.Run("first pair differs", func(t *testing.T) { + input := mustCompile(t, ctx, `{a: "foo", b: "baz", c: "bar", d: "bar"}`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 matches, got %d", len(got)) + } + }) + + t.Run("second pair differs", func(t *testing.T) { + input := mustCompile(t, ctx, `{a: "foo", b: "foo", c: "bar", d: "qux"}`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 matches, got %d", len(got)) + } + }) +} + +// TestEvaluate_Bind_ExplainEnabled_BindingFailureEmitsDiagnostic confirms +// that a binding mismatch emits an E0601 diagnostic when explain is enabled. +func TestEvaluate_Bind_ExplainEnabled_BindingFailureEmitsDiagnostic(t *testing.T) { + evaluator.SetExplainEnabled(true) + t.Cleanup(func() { evaluator.SetExplainEnabled(false) }) + + dir := t.TempDir() + mustWriteRule(t, dir, "bind_diag.cue", `{ + when: { + a: string @bind(X) + b: string @bind(X) + } + then: deny: {rule_id: "bind-diag", reason: "a equals b"} + }`) + rules := loadRules(t, dir) + + ctx := cuecontext.New() + input := mustCompile(t, ctx, `{a: "hello", b: "world"}`) + + got, diags, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 matches, got %d", len(got)) + } + if len(diags) == 0 { + t.Fatal("expected at least one diagnostic for binding failure, got none") + } + if diags[0].Code != "E0601" { + t.Errorf("diagnostic code = %q, want E0601", diags[0].Code) + } +} + +// TestEvaluate_Bind_NoBindings_RuleUnchanged confirms that rules without +// @bind attributes behave exactly as before. +func TestEvaluate_Bind_NoBindings_RuleUnchanged(t *testing.T) { + dir := t.TempDir() + mustWriteRule(t, dir, "no_bind.cue", `{ + when: {tool_name: "Bash"} + then: deny: {rule_id: "no-bind", reason: "bash"} + }`) + rules := loadRules(t, dir) + if len(rules[0].Bindings) != 0 { + t.Fatalf("expected 0 bindings on rule without @bind, got %d", len(rules[0].Bindings)) + } + + ctx := cuecontext.New() + input := mustCompile(t, ctx, `{hook_event_name: "PreToolUse", tool_name: "Bash"}`) + + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 match, got %d", len(got)) + } +} + +// TestEvaluate_Bind_NestedStructPath confirms that @bind works on fields +// nested deep in the when struct. +func TestEvaluate_Bind_NestedStructPath(t *testing.T) { + dir := t.TempDir() + mustWriteRule(t, dir, "bind_nested.cue", `{ + when: { + tool_input: { + command: string @bind(X) + parsed: { + subcommands: [...string] @bind(X, 0) + } + } + } + then: deny: {rule_id: "bind-nested", reason: "command equals first subcommand"} + }`) + rules := loadRules(t, dir) + + ctx := cuecontext.New() + + t.Run("equal", func(t *testing.T) { + input := mustCompile(t, ctx, `{ + tool_input: { + command: "git" + parsed: subcommands: ["git", "push"] + } + }`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 match, got %d", len(got)) + } + }) + + t.Run("different", func(t *testing.T) { + input := mustCompile(t, ctx, `{ + tool_input: { + command: "git" + parsed: subcommands: ["svn", "push"] + } + }`) + got, _, err := evaluator.Evaluate(rules, input) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 matches, got %d", len(got)) + } + }) +} diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index 3862d8a..41642bc 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -70,6 +70,12 @@ func Evaluate(rules []config.Rule, input cue.Value) ([]Match, []diag.Diagnostic, } if rule.When.Subsume(input, cue.Final(), cue.Schema()) == nil { + if bf := checkBindings(rule.Bindings, input); bf != nil { + if explainEnabled() { + diags = append(diags, bindingDiagnostic(rule, bf)) + } + continue + } matches = append(matches, Match{Rule: rule, Action: rule.Then}) continue } diff --git a/tests/diagnostics.md b/tests/diagnostics.md index e394c30..ba8c9c4 100644 --- a/tests/diagnostics.md +++ b/tests/diagnostics.md @@ -28,7 +28,12 @@ Fixture rules live under `tests/diagnostics_rules*/`: Provenance footer. - `tests/diagnostics_rules_broken_scope/` — load-time E0501. - `tests/diagnostics_rules_broken_cross/` — load-time E0502. +- `tests/diagnostics_rules_broken_let/` — load-time E0506 (`let` in `when`). +- `tests/diagnostics_rules_broken_if/` — load-time E0507 (`if` comprehension + in `when`). - `tests/diagnostics_rules_broken_len/` — load-time E0508 (`len()` in `when`). +- `tests/diagnostics_rules_bind/` — E0601 (`BindingMismatch`, `@bind` + variable equality). Each block redirects stderr into stdout (`2>&1`) so scrut — which only captures stdout by default — sees the diagnostic stream. The @@ -392,6 +397,79 @@ underscore) at the file top level where both rules can see it. [1] ``` +## E0506 — `let` clause in `when` (load-time) + +`tests/diagnostics_rules_broken_let/let_in_when.cue` uses a `let` clause to +name an input-derived path inside `when`. The `let` binds the pattern's type +(e.g. `string`), not the input's concrete value, so any downstream constraint +silently misfires. The loader rejects the directory with an E0506 diagnostic +and the CLI exits 1. + +```scrut +$ cat << 'EOF' | +> { +> "hook_event_name": "PreToolUse", +> "tool_name": "Bash", +> "tool_input": {"command": "ls"}, +> "session_id": "test", +> "cwd": "/tmp" +> } +> EOF +> fas eval --harness claude --config tests/diagnostics_rules_broken_let --global-config /tmp/fas-nonexistent-global 2>&1 +error[E0506]: rule "let_rule": `let` clause in `when` binds the pattern type, not the input value + --> tests/diagnostics_rules_broken_let/let_in_when.cue:7:3 + | +7 | let cmd = tool_input.command + | ^^^ `let` in `when` of rule "let_rule" + | + = help: A `let` clause inside `when` binds the pattern's type, not the input's value. + +`let cmd = tool_input.command` names the `command` path inside the pattern, +which is a type constraint (e.g. `string`), not the concrete value from +the input. A downstream constraint like `cmd & =~"^git"` then matches +every input whose command is a string, not just git commands. Remove +the `let` and place the constraint directly on the field. + +[1] +``` + +## E0507 — `if` comprehension in `when` (load-time) + +`tests/diagnostics_rules_broken_if/if_in_when.cue` uses an `if` comprehension +to conditionally add constraints inside `when`. The guard evaluates against the +pattern's own types, not the input's values, so the gated fields either always +appear or never appear. The loader rejects the directory with an E0507 +diagnostic and the CLI exits 1. + +```scrut +$ cat << 'EOF' | +> { +> "hook_event_name": "PreToolUse", +> "tool_name": "Bash", +> "tool_input": {"command": "ls"}, +> "session_id": "test", +> "cwd": "/tmp" +> } +> EOF +> fas eval --harness claude --config tests/diagnostics_rules_broken_if --global-config /tmp/fas-nonexistent-global 2>&1 +error[E0507]: rule "if_rule": `if` guard in `when` evaluates against the pattern, not the input + --> tests/diagnostics_rules_broken_if/if_in_when.cue:12:4 + | +12 | if list.Contains(flags, "--force") { + | ^^ `if` guard in `when` of rule "if_rule" + | + = help: A comprehension (`if` or `for`) inside `when` evaluates against the pattern, not the input. + +`if list.Contains(flags, "--force") { ... }` evaluates the guard against +the pattern's own `flags` field, which is a type (e.g. `[...string]`), +not a concrete list from the input. The guarded fields either always +appear or never appear, regardless of what the input carries. Remove +the comprehension and express the constraint directly as a field +pattern (e.g. `flags: list.MatchN(>0, =~"--force")`). + +[1] +``` + ## E0508 — `len()` in `when` (load-time) `tests/diagnostics_rules_broken_len/len_in_when.cue` uses `len(flags)` inside @@ -586,6 +664,60 @@ error[E0201]: key not found = help: tool_input has keys: file_path ``` +## E0601 — binding variable mismatch + +The `bind_eq` rule annotates `parsed.targets` with `@bind(Path, 0)` and +`@bind(Path, 1)`, declaring that the source (first target) must equal the +destination (second target) — a self-referencing copy/move. When the input +carries `mv src.go dst.go` (targets[0]="src.go", targets[1]="dst.go") +subsumption passes but the binding check fails, producing an E0601 +diagnostic with `= note:` footers showing each path's resolved value. + +```scrut +$ cat << 'EOF' | +> { +> "hook_event_name": "PreToolUse", +> "tool_name": "Bash", +> "tool_input": {"command": "mv src.go dst.go"}, +> "session_id": "test", +> "cwd": "/tmp" +> } +> EOF +> fas explain bind-eq --config tests/diagnostics_rules_bind --global-config /tmp/fas-nonexistent-global 2>&1 +error[E0601]: binding variable mismatch + --> tests/diagnostics_rules_bind/bind_eq.cue:10:44 + | +10 | tool_input: parsed: targets: [...string] @bind(Path, 0) @bind(Path, 1) + | ^^^^^^^^^^^ @bind(Path): values differ + | + = help: Fields annotated with the same @bind variable resolved to different values. + +Two or more fields in `when` carry `@bind(X)` with the same variable +name. At match time, the concrete input values at those paths must be +equal (they must unify to the same point in the lattice). This diagnostic +fires when the input structurally matched the pattern but the bound values +diverged — e.g. `command` was `"cat"` while `targets[0]` was `"dog"`. + = note: tool_input.parsed.targets[0] = "src.go" + = note: tool_input.parsed.targets[1] = "dst.go" +[1] +``` + +When source and destination are the same (`mv src.go src.go`) the binding is +satisfied and the rule fires silently (exit 0, no diagnostic output). + +```scrut +$ cat << 'EOF' | +> { +> "hook_event_name": "PreToolUse", +> "tool_name": "Bash", +> "tool_input": {"command": "mv src.go src.go"}, +> "session_id": "test", +> "cwd": "/tmp" +> } +> EOF +> fas explain bind-eq --config tests/diagnostics_rules_bind --global-config /tmp/fas-nonexistent-global 2>&1 +``` + ## `fas explain` exit codes — match, no-match, unknown rule Exit 0 when the rule fires, exit 1 when it does not (and a diagnostic is diff --git a/tests/diagnostics_rules_bind/bind_eq.cue b/tests/diagnostics_rules_bind/bind_eq.cue new file mode 100644 index 0000000..109fb66 --- /dev/null +++ b/tests/diagnostics_rules_bind/bind_eq.cue @@ -0,0 +1,17 @@ +package rules + +// Source and destination bound to the same variable: the first and second +// targets must be equal. Catches self-referencing mv/cp/ln operations. +// When they differ the evaluator emits E0601 under --explain. +bind_eq: { + when: { + hook_event_name: "PreToolUse" + tool_name: "Bash" + tool_input: parsed: targets: [...string] @bind(Path, 0) @bind(Path, 1) + } + then: deny: { + rule_id: "bind-eq" + reason: "source and destination are the same" + severity: "MEDIUM" + } +} diff --git a/tests/diagnostics_rules_broken_if/if_in_when.cue b/tests/diagnostics_rules_broken_if/if_in_when.cue new file mode 100644 index 0000000..c44260e --- /dev/null +++ b/tests/diagnostics_rules_broken_if/if_in_when.cue @@ -0,0 +1,21 @@ +package rules + +import "list" + +// Uses `if` comprehension inside `when` to conditionally add constraints. +// The guard evaluates against the pattern's type, not the input's value. +// Triggers E0507 at load time. +if_rule: { + when: { + tool_input: parsed: { + flags: [...string] + if list.Contains(flags, "--force") { + commands: [...=~"^git$"] + } + } + } + then: deny: { + rule_id: "if-in-when" + reason: "forced git commands blocked" + } +} diff --git a/tests/diagnostics_rules_broken_let/let_in_when.cue b/tests/diagnostics_rules_broken_let/let_in_when.cue new file mode 100644 index 0000000..adf4935 --- /dev/null +++ b/tests/diagnostics_rules_broken_let/let_in_when.cue @@ -0,0 +1,15 @@ +package rules + +// Uses `let` inside `when` to name an input path. The binding captures +// the pattern's type, not the input's value. Triggers E0506 at load time. +let_rule: { + when: { + let cmd = tool_input.command + tool_input: command: string + _check: cmd & =~"^git" + } + then: deny: { + rule_id: "let-in-when" + reason: "git commands blocked" + } +} diff --git a/tests/policies.md b/tests/policies.md index d83288d..c938cee 100644 --- a/tests/policies.md +++ b/tests/policies.md @@ -9,7 +9,7 @@ scrut test -w . tests/policies.md ``` The `--global-config /tmp/fas-nonexistent-global` flag points at a path that does not exist so host-level -rules never leak into the suite; only rules under `tests/policies/` participate. +rules never leak into the suite; only rules under `tests/policies/` or `tests/policies_bind/` participate. ## System Path Protection @@ -818,3 +818,68 @@ $ cat << 'EOF' | > fas eval --harness claude --config tests/policies --global-config /tmp/fas-nonexistent-global 2>/dev/null {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}} (no-eol) ``` + +## Binding Variables (`@bind`) + +`@bind(Var)` annotations on `when` fields declare that two input paths sharing +the same variable must resolve to equal values. The structural pattern is +checked first via CUE subsumption; the binding equality check runs second. If +the pattern matches but the bound values differ, the rule does not fire. + +### Denies self-referencing copy (source == destination) + +`cp file.txt file.txt` produces `parsed.targets = ["file.txt", "file.txt"]`. +Both sub-indices are bound to `@bind(Path)`, and the values are equal — the +rule fires. + +```scrut +$ cat << 'EOF' | +> { +> "hook_event_name": "PreToolUse", +> "tool_name": "Bash", +> "tool_input": {"command": "cp file.txt file.txt"}, +> "session_id": "test", +> "cwd": "/tmp" +> } +> EOF +> fas eval --harness claude --config tests/policies_bind --global-config /tmp/fas-nonexistent-global 2>/dev/null +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Source and destination are the same file"}} (no-eol) +``` + +### Allows move with different source and destination + +`mv src.go dst.go` produces `parsed.targets = ["src.go", "dst.go"]`. The +binding values differ — the rule does not fire. + +```scrut +$ cat << 'EOF' | +> { +> "hook_event_name": "PreToolUse", +> "tool_name": "Bash", +> "tool_input": {"command": "mv src.go dst.go"}, +> "session_id": "test", +> "cwd": "/tmp" +> } +> EOF +> fas eval --harness claude --config tests/policies_bind --global-config /tmp/fas-nonexistent-global 2>/dev/null +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}} (no-eol) +``` + +### Allows non-Bash tools (structural mismatch before binding check) + +A `Read` event fails the subsumption check against `tool.#Bash` before +bindings are even evaluated — the rule does not fire. + +```scrut +$ cat << 'EOF' | +> { +> "hook_event_name": "PreToolUse", +> "tool_name": "Read", +> "tool_input": {"file_path": "/tmp/f"}, +> "session_id": "test", +> "cwd": "/tmp" +> } +> EOF +> fas eval --harness claude --config tests/policies_bind --global-config /tmp/fas-nonexistent-global 2>/dev/null +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}} (no-eol) +``` diff --git a/tests/policies_bind/command_target.cue b/tests/policies_bind/command_target.cue new file mode 100644 index 0000000..6602011 --- /dev/null +++ b/tests/policies_bind/command_target.cue @@ -0,0 +1,20 @@ +package rules + +import ( + "github.com/srnnkls/fas/cue/hook" + "github.com/srnnkls/fas/cue/tool" +) + +// Deny when a move or copy command's source and destination are the same file. +// Exercises @bind path equality at the policy level: targets[0] must equal +// targets[1] for the rule to fire. +self_copy: { + when: hook.#PreToolUse & tool.#Bash & { + tool_input: parsed: targets: [...string] @bind(Path, 0) @bind(Path, 1) + } + then: deny: { + rule_id: "self-copy" + reason: "Source and destination are the same file" + severity: "LOW" + } +}