From 4a99b514c018c1d900b4f0034d4ba5676ef7f260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Nikolaus?= Date: Sat, 27 Jun 2026 22:05:53 +0200 Subject: [PATCH] feat(parser): group per-command targets in parsed.calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat parsed.commands/targets lists cannot bind a verb to the target it acts on, so `cat README && rm .env` puts a read verb and a secret into the same lists and a rule keying off both over-matches. parsed.calls groups each invocation with its own command, subcommand, action, targets, and flags, so a rule can match a single call that both runs a read verb and targets a secret. This is parse-time pre-joining, not a relational match: the engine still matches by subsumption, and list.MatchN(>0, {command, targets}) over calls is an ordinary existential over one struct's fields. calls_join_test pins it — the flat shape matches both `cat .env` and `cat README && rm .env`; the join matches only the genuine read. --- cue/schema.cue | 1 + internal/contract/calls_join_test.go | 77 +++++++++++++++++++++++++ internal/parser/bash.go | 11 +++- internal/parser/bash_calls_test.go | 84 ++++++++++++++++++++++++++++ internal/parser/parsed.go | 10 ++++ 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 internal/contract/calls_join_test.go create mode 100644 internal/parser/bash_calls_test.go diff --git a/cue/schema.cue b/cue/schema.cue index 5df348d..40f5f80 100644 --- a/cue/schema.cue +++ b/cue/schema.cue @@ -51,6 +51,7 @@ package fas subcommands?: _ targets?: _ flags?: _ + calls?: _ attributes?: {...} ... } diff --git a/internal/contract/calls_join_test.go b/internal/contract/calls_join_test.go new file mode 100644 index 0000000..4e5e5f5 --- /dev/null +++ b/internal/contract/calls_join_test.go @@ -0,0 +1,77 @@ +package contract + +import ( + "encoding/json" + "testing" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + + "github.com/srnnkls/fas/internal/parser" +) + +func bashInput(t *testing.T, ctx *cue.Context, command string) cue.Value { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": map[string]any{ + "command": command, + "parsed": parser.ParseBash(command), + }, + }) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + v := ctx.CompileBytes(raw) + if err := v.Err(); err != nil { + t.Fatalf("compile input: %v", err) + } + return v +} + +func matches(pattern, input cue.Value) bool { + return pattern.Subsume(input, cue.Final(), cue.Schema()) == nil +} + +const ( + readVerbsFlat = `commands: list.MatchN(>0, "cat" | "less" | "head")` + secretFlat = `targets: list.MatchN(>0, =~"(^|/)\\.env$")` +) + +func TestCallsJoin_DistinguishesReadFromCoincidence(t *testing.T) { + ctx := cuecontext.New() + + flat := ctx.CompileString(`import "list" +{tool_input: parsed: {` + readVerbsFlat + `, ` + secretFlat + `, ...}, ...}`) + if err := flat.Err(); err != nil { + t.Fatalf("compile flat pattern: %v", err) + } + + join := ctx.CompileString(`import "list" +{tool_input: parsed: calls: list.MatchN(>0, { + command: "cat" | "less" | "head" + targets: list.MatchN(>0, =~"(^|/)\\.env$") + ... +}), ...}`) + if err := join.Err(); err != nil { + t.Fatalf("compile join pattern: %v", err) + } + + realRead := bashInput(t, ctx, "cat .env") + coincidence := bashInput(t, ctx, "cat README && rm .env") + + if !matches(flat, realRead) { + t.Error("flat rule must match a real read of .env") + } + if !matches(flat, coincidence) { + t.Error("flat rule is expected to over-match the coincidence (read verb + secret in separate calls)") + } + + if !matches(join, realRead) { + t.Error("join rule must match a real read of .env") + } + if matches(join, coincidence) { + t.Error("join rule must NOT match when no single call both reads and targets the secret") + } +} diff --git a/internal/parser/bash.go b/internal/parser/bash.go index c23388a..e047f5d 100644 --- a/internal/parser/bash.go +++ b/internal/parser/bash.go @@ -154,9 +154,18 @@ func extractCall(call *syntax.CallExpr, out *Parsed) { if subIdx >= 0 { subcommand = rest[subIdx].text } - if verb := resolveVerb(name, subcommand, flags); verb != "" { + verb := resolveVerb(name, subcommand, flags) + if verb != "" { out.Actions = append(out.Actions, verb) } + + out.Calls = append(out.Calls, Call{ + Command: name, + Subcommand: subcommand, + Action: verb, + Targets: positional, + Flags: flags, + }) } // isKnownSubcommand reports whether ` ` is registered in any diff --git a/internal/parser/bash_calls_test.go b/internal/parser/bash_calls_test.go new file mode 100644 index 0000000..0d477aa --- /dev/null +++ b/internal/parser/bash_calls_test.go @@ -0,0 +1,84 @@ +package parser_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/srnnkls/fas/internal/parser" +) + +func findCall(calls []parser.Call, command string) (parser.Call, bool) { + for _, c := range calls { + if c.Command == command { + return c, true + } + } + return parser.Call{}, false +} + +func TestBash_Calls_GroupTargetsPerCommand(t *testing.T) { + got := parser.ParseBash("cat README && rm .env") + + cat, ok := findCall(got.Calls, "cat") + if !ok { + t.Fatalf("calls %+v missing cat call", got.Calls) + } + rm, ok := findCall(got.Calls, "rm") + if !ok { + t.Fatalf("calls %+v missing rm call", got.Calls) + } + + if diff := cmp.Diff([]string{"README"}, cat.Targets); diff != "" { + t.Errorf("cat call targets (-want +got):\n%s", diff) + } + if diff := cmp.Diff([]string{".env"}, rm.Targets); diff != "" { + t.Errorf("rm call targets (-want +got):\n%s", diff) + } + + for _, c := range got.Calls { + if c.Command == "cat" { + for _, target := range c.Targets { + if target == ".env" { + t.Errorf("read verb %q must not be grouped with secret target .env", c.Command) + } + } + } + } +} + +func TestBash_Calls_ReadOfSecretGroupsTogether(t *testing.T) { + got := parser.ParseBash("cat .env") + + if len(got.Calls) != 1 { + t.Fatalf("want 1 call, got %d: %+v", len(got.Calls), got.Calls) + } + c := got.Calls[0] + if c.Command != "cat" { + t.Errorf("call command = %q, want cat", c.Command) + } + if diff := cmp.Diff([]string{".env"}, c.Targets); diff != "" { + t.Errorf("call targets (-want +got):\n%s", diff) + } + if c.Action != "read" { + t.Errorf("call action = %q, want read", c.Action) + } +} + +func TestBash_Calls_CarrySubcommandAndFlags(t *testing.T) { + got := parser.ParseBash("git add -f .env") + + c, ok := findCall(got.Calls, "git") + if !ok { + t.Fatalf("calls %+v missing git call", got.Calls) + } + if c.Subcommand != "add" { + t.Errorf("subcommand = %q, want add", c.Subcommand) + } + if diff := cmp.Diff([]string{".env"}, c.Targets); diff != "" { + t.Errorf("git call targets (-want +got):\n%s", diff) + } + if !cmp.Equal([]string{"-f"}, c.Flags) { + t.Errorf("git call flags = %v, want [-f]", c.Flags) + } +} diff --git a/internal/parser/parsed.go b/internal/parser/parsed.go index c295e79..50b9460 100644 --- a/internal/parser/parsed.go +++ b/internal/parser/parsed.go @@ -17,5 +17,15 @@ type Parsed struct { Subcommands []string `json:"subcommands"` Targets []string `json:"targets"` Flags []string `json:"flags"` + Calls []Call `json:"calls"` Attributes map[string]any `json:"attributes,omitempty"` } + +// Call groups one resolved command invocation with its own targets and flags. +type Call struct { + Command string `json:"command"` + Subcommand string `json:"subcommand,omitempty"` + Action string `json:"action,omitempty"` + Targets []string `json:"targets"` + Flags []string `json:"flags"` +}