Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cue/schema.cue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ package fas
subcommands?: _
targets?: _
flags?: _
calls?: _
attributes?: {...}
...
}
Expand Down
77 changes: 77 additions & 0 deletions internal/contract/calls_join_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
11 changes: 10 additions & 1 deletion internal/parser/bash.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name> <candidate>` is registered in any
Expand Down
84 changes: 84 additions & 0 deletions internal/parser/bash_calls_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 10 additions & 0 deletions internal/parser/parsed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Loading