From 6cda17f18bc222d6f7e8f79789f2202257f264b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Sun, 28 Jun 2026 10:10:23 +0000 Subject: [PATCH 1/6] feat(evaluator): add @bind lattice variables for path equality constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce @bind(Var) attributes on `when` clause fields to express that two input paths must resolve to equal concrete values. Variables are deferred lattice points: subsumption checks the static pattern first, then a post-Subsume pass groups bindings by variable name and verifies mutual subsumption of the resolved values. New files: - config/bind.go: Binding type, AST extraction (extractBindings/walkBindings/parseBindAttr) - evaluator/bind.go: checkBindings, resolvePath, allEqual, E0601 diagnostic Also adds predeclared CUE type identifiers (string, int, bool, etc.) to the lint's allowed set — the standalone parser doesn't mark them via IsPredeclared(), so they previously false-positived as E0501. --- internal/config/bind.go | 151 +++++++++++++ internal/config/bind_test.go | 220 +++++++++++++++++++ internal/config/lint.go | 33 ++- internal/config/loader.go | 10 + internal/diag/codes.go | 16 +- internal/diag/codes_e0504_test.go | 4 +- internal/diag/codes_e0505_test.go | 4 +- internal/diag/codes_test.go | 6 +- internal/diag/reason.go | 11 + internal/diag/render_reason.go | 17 ++ internal/evaluator/bind.go | 180 +++++++++++++++ internal/evaluator/bind_test.go | 350 ++++++++++++++++++++++++++++++ internal/evaluator/evaluator.go | 6 + 13 files changed, 999 insertions(+), 9 deletions(-) create mode 100644 internal/config/bind.go create mode 100644 internal/config/bind_test.go create mode 100644 internal/evaluator/bind.go create mode 100644 internal/evaluator/bind_test.go diff --git a/internal/config/bind.go b/internal/config/bind.go new file mode 100644 index 0000000..5c5a6c6 --- /dev/null +++ b/internal/config/bind.go @@ -0,0 +1,151 @@ +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 { + 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..3012f07 100644 --- a/internal/config/lint.go +++ b/internal/config/lint.go @@ -292,6 +292,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 +357,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 } 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..e888d64 100644 --- a/internal/diag/codes.go +++ b/internal/diag/codes.go @@ -205,9 +205,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 = 19 // codeRegistry maps each stable code string to its CodeInfo. // Built at package init so that duplicate codes fail loudly rather @@ -218,6 +231,7 @@ var codeRegistry = buildCodeRegistry( E0301, E0302, E0303, E0304, E0401, E0402, E0501, E0502, E0503, E0504, E0505, 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..c148ab4 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 != 19 { + t.Errorf("CodesInScopeV1 = %d, want 19 after E0504 joins the registry", CodesInScopeV1) } } diff --git a/internal/diag/codes_e0505_test.go b/internal/diag/codes_e0505_test.go index a7b0cb9..4868f82 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 != 19 { + t.Errorf("CodesInScopeV1 = %d, want 19 (E0504 + E0505 registered)", CodesInScopeV1) } } diff --git a/internal/diag/codes_test.go b/internal/diag/codes_test.go index a055c94..694d06f 100644 --- a/internal/diag/codes_test.go +++ b/internal/diag/codes_test.go @@ -42,6 +42,8 @@ func expectedCodes() []expectedCode { {"E0504", "scope/binding", func() diag.CodeInfo { return diag.E0504 }}, {"E0505", "scope/binding", func() diag.CodeInfo { return diag.E0505 }}, {"E0508", "scope/binding", func() diag.CodeInfo { return diag.E0508 }}, + // E06xx — lattice binding + {"E0601", "lattice binding", func() diag.CodeInfo { return diag.E0601 }}, } } @@ -74,7 +76,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 +187,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 = 19 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 } From 97e0a25de833020ec13843ac838cd06b2a2aae38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Sun, 28 Jun 2026 12:01:58 +0000 Subject: [PATCH 2/6] test: add scrut integration tests for @bind variables; update README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add E0601 diagnostic test (diagnostics.md) and policy-level bind tests (policies.md) exercising the match/no-match/structural-mismatch paths. Fixture rules under tests/diagnostics_rules_bind/ and tests/policies_bind/. Fix walkBindings to descend through BinaryExpr nodes — stdlib-imported rules use `hook.#PreToolUse & tool.#Bash & { ... }`, which wraps the struct literal in a BinaryExpr chain. Without this fix, @bind attributes inside such rules were silently ignored. Add @bind section to README documenting syntax, semantics, and the sub-path indexing feature. --- README.md | 42 +++++++++++++++ internal/config/bind.go | 9 ++++ tests/diagnostics.md | 56 ++++++++++++++++++++ tests/diagnostics_rules_bind/bind_eq.cue | 20 +++++++ tests/policies.md | 67 +++++++++++++++++++++++- tests/policies_bind/command_target.cue | 22 ++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 tests/diagnostics_rules_bind/bind_eq.cue create mode 100644 tests/policies_bind/command_target.cue diff --git a/README.md b/README.md index 3caf786..84cfec2 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,48 @@ on stdin and writes the response on stdout: fas eval --harness claude < event.json ``` +## Binding variables (`@bind`) + +Rules can declare that two input paths must carry equal values using `@bind` +attributes. Annotate fields in `when` with `@bind(VarName)` — any two fields +sharing the same variable must resolve to the same concrete value in the input: + +```cue +// .fas/rules/self_reference.cue +package rules + +import ( + "github.com/srnnkls/fas/cue/hook" + "github.com/srnnkls/fas/cue/tool" +) + +self_reference: { + when: hook.#PreToolUse & tool.#Bash & { + tool_input: parsed: { + commands: [...string] @bind(X, 0) + targets: [...string] @bind(X, 0) + } + } + then: deny: { + rule_id: "self-reference" + reason: "Command name equals its own first target" + severity: "LOW" + } +} +``` + +The structural pattern is checked first via CUE subsumption; the binding +equality check runs as a second pass. A single variable referenced once is a +capture (no constraint). Two or more fields sharing a variable must all unify +to the same concrete value. + +`@bind(Var, SubPath)` supports an optional sub-path — typically a list index +(`@bind(X, 0)` selects the first element). Variable names must start with an +uppercase letter. + +When `--explain` is enabled and a binding fails, *fas* emits an E0601 +diagnostic showing each path and its resolved value. + ## Organizing rules A rules directory is loaded as CUE **packages**, recursively. Each directory diff --git a/internal/config/bind.go b/internal/config/bind.go index 5c5a6c6..e2e11fc 100644 --- a/internal/config/bind.go +++ b/internal/config/bind.go @@ -50,6 +50,15 @@ func extractBindings(whenExpr ast.Expr) ([]Binding, error) { } 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 diff --git a/tests/diagnostics.md b/tests/diagnostics.md index e394c30..a781aa6 100644 --- a/tests/diagnostics.md +++ b/tests/diagnostics.md @@ -29,6 +29,8 @@ Fixture rules live under `tests/diagnostics_rules*/`: - `tests/diagnostics_rules_broken_scope/` — load-time E0501. - `tests/diagnostics_rules_broken_cross/` — load-time E0502. - `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 @@ -586,6 +588,60 @@ error[E0201]: key not found = help: tool_input has keys: file_path ``` +## E0601 — binding variable mismatch + +The `bind_eq` rule annotates `parsed.commands` and `parsed.targets` with +`@bind(X, 0)`, declaring that the first command name must equal the first +target. When the input carries `cat /etc/passwd` (commands[0]="cat", +targets[0]="/etc/passwd") 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": "cat /etc/passwd"}, +> "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:11:26 + | +11 | commands: [...string] @bind(X, 0) + | ^^^^^^^^ @bind(X): 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.commands[0] = "cat" + = note: tool_input.parsed.targets[0] = "/etc/passwd" +[1] +``` + +When the first command name and the first target are the same 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": "cat cat"}, +> "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..4fe5564 --- /dev/null +++ b/tests/diagnostics_rules_bind/bind_eq.cue @@ -0,0 +1,20 @@ +package rules + +// Two fields bound to the same variable: the first parsed command must equal +// the first parsed target. When they differ the evaluator emits E0601 under +// --explain. +bind_eq: { + when: { + hook_event_name: "PreToolUse" + tool_name: "Bash" + tool_input: parsed: { + commands: [...string] @bind(X, 0) + targets: [...string] @bind(X, 0) + } + } + then: deny: { + rule_id: "bind-eq" + reason: "command name equals first target" + severity: "MEDIUM" + } +} diff --git a/tests/policies.md b/tests/policies.md index d83288d..72e4192 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(X)` 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 when command name equals first target + +`cat cat` produces `parsed.commands[0] = "cat"` and `parsed.targets[0] = "cat"`. +Both paths are bound to `@bind(X, 0)`, and the values are equal — the rule fires. + +```scrut +$ cat << 'EOF' | +> { +> "hook_event_name": "PreToolUse", +> "tool_name": "Bash", +> "tool_input": {"command": "cat cat"}, +> "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":"Command equals its own first target"}} (no-eol) +``` + +### Allows when command name differs from first target + +`cat /etc/passwd` produces `parsed.commands[0] = "cat"` and +`parsed.targets[0] = "/etc/passwd"`. The binding values differ — the rule +does not fire. + +```scrut +$ cat << 'EOF' | +> { +> "hook_event_name": "PreToolUse", +> "tool_name": "Bash", +> "tool_input": {"command": "cat /etc/passwd"}, +> "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..b0dd2ff --- /dev/null +++ b/tests/policies_bind/command_target.cue @@ -0,0 +1,22 @@ +package rules + +import ( + "github.com/srnnkls/fas/cue/hook" + "github.com/srnnkls/fas/cue/tool" +) + +// Deny when the first parsed target exactly equals the parsed command name. +// Exercises @bind path equality at the policy level. +command_is_target: { + when: hook.#PreToolUse & tool.#Bash & { + tool_input: parsed: { + commands: [...string] @bind(X, 0) + targets: [...string] @bind(X, 0) + } + } + then: deny: { + rule_id: "command-is-target" + reason: "Command equals its own first target" + severity: "LOW" + } +} From b364b5850cfdba25abb1c30d5e79ab984bf2b1b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Sun, 28 Jun 2026 12:50:33 +0000 Subject: [PATCH 3/6] refactor: replace contrived @bind examples with realistic scenarios Replace "commands[0] == targets[0]" pattern (which produced the absurd "cat cat" test case) with two realistic patterns: - Self-referencing copy/move: targets[0] == targets[1] catches `cp file.txt file.txt` or `mv src.go src.go`. - Deleting the working directory: cwd == targets[0] catches `rm -rf /home/user/project` when run from that directory. Update scrut tests, fixture rules, and README accordingly. --- README.md | 58 ++++++++++++++++++------ tests/diagnostics.md | 30 ++++++------ tests/diagnostics_rules_bind/bind_eq.cue | 13 ++---- tests/policies.md | 22 ++++----- tests/policies_bind/command_target.cue | 16 +++---- 5 files changed, 82 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 84cfec2..437228c 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,21 @@ fas eval --harness claude < event.json Rules can declare that two input paths must carry equal values using `@bind` attributes. Annotate fields in `when` with `@bind(VarName)` — any two fields -sharing the same variable must resolve to the same concrete value in the input: +sharing the same variable must resolve to the same concrete value in the input. + +CUE subsumption checks a pattern against the input but cannot express "field A +must equal field B" — the pattern doesn't see the input's own values during +matching. `@bind` fills that gap: the structural pattern is checked first, then +a second pass verifies that all paths sharing a variable resolved to the same +concrete value. + +### Self-referencing copy/move + +Block `cp` or `mv` when source and destination are the same file. Two +`@bind(Path, N)` annotations on the same field select different list elements: ```cue -// .fas/rules/self_reference.cue +// .fas/rules/self_copy.cue package rules import ( @@ -111,29 +122,48 @@ import ( "github.com/srnnkls/fas/cue/tool" ) -self_reference: { +self_copy: { when: hook.#PreToolUse & tool.#Bash & { - tool_input: parsed: { - commands: [...string] @bind(X, 0) - targets: [...string] @bind(X, 0) - } + tool_input: parsed: targets: [...string] @bind(Path, 0) @bind(Path, 1) } then: deny: { - rule_id: "self-reference" - reason: "Command name equals its own first target" + rule_id: "self-copy" + reason: "Source and destination are the same file" severity: "LOW" } } ``` -The structural pattern is checked first via CUE subsumption; the binding -equality check runs as a second pass. A single variable referenced once is a -capture (no constraint). Two or more fields sharing a variable must all unify -to the same concrete value. +`cp file.txt file.txt` fires (targets[0] == targets[1]); `mv a.go b.go` does +not (targets differ). + +### Deleting the working directory + +Bind a top-level field (`cwd`) against a nested field (`parsed.targets[0]`): + +```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" + } +} +``` + +`rm -rf /home/user/project` when `cwd` is `/home/user/project` fires; +`rm -rf /tmp/junk` from the same directory does not. + +### Syntax `@bind(Var, SubPath)` supports an optional sub-path — typically a list index (`@bind(X, 0)` selects the first element). Variable names must start with an -uppercase letter. +uppercase letter. A variable referenced only once is a capture with no +constraint. When `--explain` is enabled and a binding fails, *fas* emits an E0601 diagnostic showing each path and its resolved value. diff --git a/tests/diagnostics.md b/tests/diagnostics.md index a781aa6..dd1fd06 100644 --- a/tests/diagnostics.md +++ b/tests/diagnostics.md @@ -590,29 +590,29 @@ error[E0201]: key not found ## E0601 — binding variable mismatch -The `bind_eq` rule annotates `parsed.commands` and `parsed.targets` with -`@bind(X, 0)`, declaring that the first command name must equal the first -target. When the input carries `cat /etc/passwd` (commands[0]="cat", -targets[0]="/etc/passwd") subsumption passes but the binding check fails, -producing an E0601 diagnostic with `= note:` footers showing each path's -resolved value. +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": "cat /etc/passwd"}, +> "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:11:26 + --> tests/diagnostics_rules_bind/bind_eq.cue:10:44 | -11 | commands: [...string] @bind(X, 0) - | ^^^^^^^^ @bind(X): values differ +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. @@ -621,20 +621,20 @@ 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.commands[0] = "cat" - = note: tool_input.parsed.targets[0] = "/etc/passwd" + = note: tool_input.parsed.targets[0] = "src.go" + = note: tool_input.parsed.targets[1] = "dst.go" [1] ``` -When the first command name and the first target are the same the binding -is satisfied and the rule fires silently (exit 0, no diagnostic output). +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": "cat cat"}, +> "tool_input": {"command": "mv src.go src.go"}, > "session_id": "test", > "cwd": "/tmp" > } diff --git a/tests/diagnostics_rules_bind/bind_eq.cue b/tests/diagnostics_rules_bind/bind_eq.cue index 4fe5564..109fb66 100644 --- a/tests/diagnostics_rules_bind/bind_eq.cue +++ b/tests/diagnostics_rules_bind/bind_eq.cue @@ -1,20 +1,17 @@ package rules -// Two fields bound to the same variable: the first parsed command must equal -// the first parsed target. When they differ the evaluator emits E0601 under -// --explain. +// 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: { - commands: [...string] @bind(X, 0) - targets: [...string] @bind(X, 0) - } + tool_input: parsed: targets: [...string] @bind(Path, 0) @bind(Path, 1) } then: deny: { rule_id: "bind-eq" - reason: "command name equals first target" + reason: "source and destination are the same" severity: "MEDIUM" } } diff --git a/tests/policies.md b/tests/policies.md index 72e4192..c938cee 100644 --- a/tests/policies.md +++ b/tests/policies.md @@ -821,42 +821,42 @@ $ cat << 'EOF' | ## Binding Variables (`@bind`) -`@bind(X)` annotations on `when` fields declare that two input paths sharing +`@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 when command name equals first target +### Denies self-referencing copy (source == destination) -`cat cat` produces `parsed.commands[0] = "cat"` and `parsed.targets[0] = "cat"`. -Both paths are bound to `@bind(X, 0)`, and the values are equal — the rule fires. +`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": "cat cat"}, +> "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":"Command equals its own first target"}} (no-eol) +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Source and destination are the same file"}} (no-eol) ``` -### Allows when command name differs from first target +### Allows move with different source and destination -`cat /etc/passwd` produces `parsed.commands[0] = "cat"` and -`parsed.targets[0] = "/etc/passwd"`. The binding values differ — the rule -does not fire. +`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": "cat /etc/passwd"}, +> "tool_input": {"command": "mv src.go dst.go"}, > "session_id": "test", > "cwd": "/tmp" > } diff --git a/tests/policies_bind/command_target.cue b/tests/policies_bind/command_target.cue index b0dd2ff..6602011 100644 --- a/tests/policies_bind/command_target.cue +++ b/tests/policies_bind/command_target.cue @@ -5,18 +5,16 @@ import ( "github.com/srnnkls/fas/cue/tool" ) -// Deny when the first parsed target exactly equals the parsed command name. -// Exercises @bind path equality at the policy level. -command_is_target: { +// 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: { - commands: [...string] @bind(X, 0) - targets: [...string] @bind(X, 0) - } + tool_input: parsed: targets: [...string] @bind(Path, 0) @bind(Path, 1) } then: deny: { - rule_id: "command-is-target" - reason: "Command equals its own first target" + rule_id: "self-copy" + reason: "Source and destination are the same file" severity: "LOW" } } From 1c8bec77e09221a0619d399f5f621a1e6f52c5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Sun, 28 Jun 2026 19:57:02 +0000 Subject: [PATCH 4/6] feat(lint): reject `let` and `if`/`for` comprehensions in `when` (E0506, E0507) `let` clauses and comprehension guards inside `when` evaluate against the pattern's types, not the input's concrete values, causing rules to silently match inputs they shouldn't. The lint now rejects both at load time with clear diagnostics explaining the misfire and how to rewrite. --- AGENTS.md | 15 ++- internal/config/lint.go | 56 +++++++- internal/config/lint_diag_test.go | 126 ++++++++++++++++++ internal/diag/codes.go | 27 +++- internal/diag/codes_e0504_test.go | 4 +- internal/diag/codes_e0505_test.go | 4 +- internal/diag/codes_test.go | 4 +- tests/diagnostics.md | 76 +++++++++++ .../if_in_when.cue | 21 +++ .../let_in_when.cue | 15 +++ 10 files changed, 332 insertions(+), 16 deletions(-) create mode 100644 tests/diagnostics_rules_broken_if/if_in_when.cue create mode 100644 tests/diagnostics_rules_broken_let/let_in_when.cue 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/internal/config/lint.go b/internal/config/lint.go index 3012f07..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) @@ -468,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/diag/codes.go b/internal/diag/codes.go index e888d64..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." + ` @@ -220,7 +243,7 @@ diverged — e.g. ` + "`command`" + ` was ` + "`\"cat\"`" + ` while ` + "`target // CodesInScopeV1 freezes the code count for this scope; bumping it requires // a deliberate design review to justify adding a new code. -const CodesInScopeV1 = 19 +const CodesInScopeV1 = 21 // codeRegistry maps each stable code string to its CodeInfo. // Built at package init so that duplicate codes fail loudly rather @@ -230,7 +253,7 @@ 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, ) diff --git a/internal/diag/codes_e0504_test.go b/internal/diag/codes_e0504_test.go index c148ab4..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 != 19 { - t.Errorf("CodesInScopeV1 = %d, want 19 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 4868f82..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 != 19 { - t.Errorf("CodesInScopeV1 = %d, want 19 (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 694d06f..669369e 100644 --- a/internal/diag/codes_test.go +++ b/internal/diag/codes_test.go @@ -41,6 +41,8 @@ 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 }}, @@ -187,7 +189,7 @@ func TestCodeInfoZeroValue(t *testing.T) { // Asserts that this scope adds no new error codes. func TestNoNewCodesInScope(t *testing.T) { - const frozen = 19 + const frozen = 21 if got := diag.CodesInScopeV1; got != frozen { t.Errorf("diag.CodesInScopeV1 = %d, want %d", got, frozen) } diff --git a/tests/diagnostics.md b/tests/diagnostics.md index dd1fd06..ba8c9c4 100644 --- a/tests/diagnostics.md +++ b/tests/diagnostics.md @@ -28,6 +28,9 @@ 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). @@ -394,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 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" + } +} From 12718d7fee8ffad15e52bedef6fb75f8c83f32ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Sun, 28 Jun 2026 19:59:15 +0000 Subject: [PATCH 5/6] docs: update GUIDE.md with E0506/E0507 lint rejections Document that `let` and `if`/`for` inside `when` are now rejected at load time, add the new codes to the error table, and mention them in the troubleshooting section. --- GUIDE.md | 711 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 711 insertions(+) create mode 100644 GUIDE.md diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..e1e1ac7 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,711 @@ +# 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) +- [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 exists — see the README. + +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. + +## 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. From 8500e0295eb6a6703f0797953c09cdce98d3486c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Mon, 29 Jun 2026 04:47:14 +0000 Subject: [PATCH 6/6] docs: move @bind from README to GUIDE, rewrite section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README is a terse reference; @bind belongs in the guide where the rationale and examples have room. The GUIDE section leads with the motivating case (cwd == target), explains the two-pass mechanism, and covers the sub-path syntax — without the contrived self-copy example. --- GUIDE.md | 48 ++++++++++++++++++++++++++++++++++++- README.md | 72 ------------------------------------------------------- 2 files changed, 47 insertions(+), 73 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index e1e1ac7..150718f 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -20,6 +20,7 @@ a rule fired — or why it didn't — you're in the right place. - [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) @@ -287,7 +288,7 @@ 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 exists — see the README. +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: @@ -313,6 +314,51 @@ types rather than the input's values: `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: diff --git a/README.md b/README.md index 437228c..3caf786 100644 --- a/README.md +++ b/README.md @@ -96,78 +96,6 @@ on stdin and writes the response on stdout: fas eval --harness claude < event.json ``` -## Binding variables (`@bind`) - -Rules can declare that two input paths must carry equal values using `@bind` -attributes. Annotate fields in `when` with `@bind(VarName)` — any two fields -sharing the same variable must resolve to the same concrete value in the input. - -CUE subsumption checks a pattern against the input but cannot express "field A -must equal field B" — the pattern doesn't see the input's own values during -matching. `@bind` fills that gap: the structural pattern is checked first, then -a second pass verifies that all paths sharing a variable resolved to the same -concrete value. - -### Self-referencing copy/move - -Block `cp` or `mv` when source and destination are the same file. Two -`@bind(Path, N)` annotations on the same field select different list elements: - -```cue -// .fas/rules/self_copy.cue -package rules - -import ( - "github.com/srnnkls/fas/cue/hook" - "github.com/srnnkls/fas/cue/tool" -) - -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" - } -} -``` - -`cp file.txt file.txt` fires (targets[0] == targets[1]); `mv a.go b.go` does -not (targets differ). - -### Deleting the working directory - -Bind a top-level field (`cwd`) against a nested field (`parsed.targets[0]`): - -```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" - } -} -``` - -`rm -rf /home/user/project` when `cwd` is `/home/user/project` fires; -`rm -rf /tmp/junk` from the same directory does not. - -### Syntax - -`@bind(Var, SubPath)` supports an optional sub-path — typically a list index -(`@bind(X, 0)` selects the first element). Variable names must start with an -uppercase letter. A variable referenced only once is a capture with no -constraint. - -When `--explain` is enabled and a binding fails, *fas* emits an E0601 -diagnostic showing each path and its resolved value. - ## Organizing rules A rules directory is loaded as CUE **packages**, recursively. Each directory