| 1 | package permission |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func TestParseRule(t *testing.T) { |
| 12 | cases := []struct { |
| 13 | in string |
| 14 | wantTool string |
| 15 | wantSubj string |
| 16 | wantLit bool |
| 17 | wantOK bool |
| 18 | }{ |
| 19 | {"bash", "bash", "", false, true}, |
| 20 | {"Bash(npm run build)", "Bash", "npm run build", false, true}, |
| 21 | {"Edit(docs/**)", "Edit", "docs/**", false, true}, |
| 22 | {"bash(rm -rf*)", "bash", "rm -rf*", false, true}, |
| 23 | {" read_file ", "read_file", "", false, true}, |
| 24 | {"bash( go test ./... )", "bash", " go test ./... ", false, true}, // subject preserved verbatim |
| 25 | {"bash(echo (hi))", "bash", "echo (hi)", false, true}, // first '(' wins, trailing ')' |
| 26 | {"bash=rm *.log", "bash", "rm *.log", true, true}, // literal: '*' is not a wildcard |
| 27 | {"bash=make FOO=bar", "bash", "make FOO=bar", true, true}, // split on first '=' only |
| 28 | {"bash=echo (hi)", "bash", "echo (hi)", true, true}, // '=' before '(' → literal, parens kept |
| 29 | {"bash(make FOO=*)", "bash", "make FOO=*", false, true}, // '(' before '=' → still a glob |
| 30 | {"get-user", "get-user", "", false, true}, |
| 31 | {"Set-Content", "Set-Content", "", false, true}, |
| 32 | {"set-content", "set-content", "", false, true}, |
| 33 | {"git", "git", "", false, true}, |
| 34 | {"Get-CustomThing", "Get-CustomThing", "", false, true}, |
| 35 | {"", "", "", false, false}, |
| 36 | {"(noTool)", "", "", false, false}, |
| 37 | } |
| 38 | for _, c := range cases { |
| 39 | r, ok := ParseRule(c.in) |
| 40 | if ok != c.wantOK { |
| 41 | t.Errorf("ParseRule(%q) ok = %v, want %v", c.in, ok, c.wantOK) |
| 42 | continue |
| 43 | } |
| 44 | if ok && (r.Tool != c.wantTool || r.Subject != c.wantSubj || r.Literal != c.wantLit) { |
| 45 | t.Errorf("ParseRule(%q) = {%q,%q,lit=%v}, want {%q,%q,lit=%v}", c.in, r.Tool, r.Subject, r.Literal, c.wantTool, c.wantSubj, c.wantLit) |
| 46 | } |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func TestPowerShellLikeBareToolNamesKeepGenericRuleSemantics(t *testing.T) { |
| 51 | p := New("ask", |
| 52 | []string{"get-user"}, |
| 53 | []string{"write-report"}, |
| 54 | []string{"set-profile"}, |
| 55 | ) |
| 56 | if got := p.DecideSubject("get-user", false, ""); got != Allow { |
| 57 | t.Fatalf("bare allow tool rule = %v, want Allow", got) |
| 58 | } |
| 59 | if got := p.DecideSubject("write-report", true, ""); got != Ask { |
| 60 | t.Fatalf("bare ask tool rule = %v, want Ask", got) |
| 61 | } |
| 62 | if got := p.DecideSubject("set-profile", true, ""); got != Deny { |
| 63 | t.Fatalf("bare deny tool rule = %v, want Deny", got) |
| 64 | } |
| 65 | if got := p.DecideSubject("bash", false, "get-user --all"); got != Ask { |
| 66 | t.Fatalf("hyphenated command inherited a bare tool allow = %v, want Ask", got) |
| 67 | } |
| 68 | cmdletAllow := New("ask", []string{"Set-Content"}, nil, nil) |
| 69 | if got := cmdletAllow.DecideSubject("Set-Content", false, ""); got != Allow { |
| 70 | t.Fatalf("bare cmdlet allow tool rule = %v, want Allow", got) |
| 71 | } |
| 72 | if got := cmdletAllow.DecideSubject("bash", false, "Set-Content app.go"); got != Ask { |
| 73 | t.Fatalf("bare cmdlet allow leaked into Bash = %v, want Ask", got) |
| 74 | } |
| 75 | legacy := New("allow", nil, nil, []string{"Set-Content"}) |
| 76 | if got := legacy.DecideSubject("Set-Content", true, ""); got != Deny { |
| 77 | t.Fatalf("legacy cmdlet exact tool deny = %v, want Deny", got) |
| 78 | } |
| 79 | if got := legacy.DecideSubject("bash", false, "set-content app.go"); got != Deny { |
| 80 | t.Fatalf("legacy cmdlet Bash deny = %v, want Deny", got) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestMatchGlob(t *testing.T) { |
| 85 | cases := []struct { |
| 86 | pattern, name string |
| 87 | want bool |
| 88 | }{ |
| 89 | {"rm -rf*", "rm -rf /tmp/x", true}, // '*' crosses '/' |
| 90 | {"go test*", "go test ./...", true}, |
| 91 | {"rm *", "rm *.log", true}, |
| 92 | {"go test*", "go build", false}, |
| 93 | {"*", "anything at all", true}, |
| 94 | {"git ?ush", "git push", true}, |
| 95 | {"git ?ush", "git rush", true}, |
| 96 | {"git ?ush", "git pull", false}, |
| 97 | {"exact", "exact", true}, |
| 98 | {"exact", "exactly", false}, |
| 99 | {"a*c", "abbbc", true}, |
| 100 | {"a*c", "abbbd", false}, |
| 101 | {"*.go", "main.go", true}, |
| 102 | {"*.go", "main.rs", false}, |
| 103 | } |
| 104 | for _, c := range cases { |
| 105 | if got := matchGlob(c.pattern, c.name); got != c.want { |
| 106 | t.Errorf("matchGlob(%q, %q) = %v, want %v", c.pattern, c.name, got, c.want) |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestSubject(t *testing.T) { |
| 112 | cases := []struct { |
| 113 | args string |
| 114 | want string |
| 115 | }{ |
| 116 | {`{"command":"go test ./..."}`, "go test ./..."}, |
| 117 | {`{"file_path":"/a/b.go"}`, "/a/b.go"}, |
| 118 | {`{"path":"/c/d"}`, "/c/d"}, |
| 119 | {`{"pattern":"TODO","path":"/x"}`, "/x"}, // file_path/path beats pattern by key order |
| 120 | {`{"other":"x"}`, ""}, |
| 121 | {`{}`, ""}, |
| 122 | {``, ""}, |
| 123 | {`not json`, ""}, |
| 124 | } |
| 125 | for _, c := range cases { |
| 126 | if got := Subject(json.RawMessage(c.args)); got != c.want { |
| 127 | t.Errorf("Subject(%q) = %q, want %q", c.args, got, c.want) |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func TestSubjectsForMoveFile(t *testing.T) { |
| 133 | got := Subjects(json.RawMessage(`{"source_path":"tmp/a.md","destination_path":"secrets/a.md"}`)) |
| 134 | want := []string{"tmp/a.md", "secrets/a.md"} |
| 135 | if len(got) != len(want) { |
| 136 | t.Fatalf("Subjects length = %d (%v), want %d", len(got), got, len(want)) |
| 137 | } |
| 138 | for i := range want { |
| 139 | if got[i] != want[i] { |
| 140 | t.Fatalf("Subjects[%d] = %q, want %q (all subjects: %v)", i, got[i], want[i], got) |
| 141 | } |
| 142 | } |
| 143 | if primary := Subject(json.RawMessage(`{"source_path":"tmp/a.md","destination_path":"secrets/a.md"}`)); primary != "tmp/a.md" { |
| 144 | t.Fatalf("Subject primary = %q, want source path", primary) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestPolicyDecide(t *testing.T) { |
| 149 | p := New("ask", |
| 150 | []string{"bash(go test*)", "ls"}, |
| 151 | []string{"read_file"}, // force a prompt even though readers default allow |
| 152 | []string{"bash(rm -rf*)"}, |
| 153 | ) |
| 154 | |
| 155 | cases := []struct { |
| 156 | name string |
| 157 | tool string |
| 158 | readOnly bool |
| 159 | args string |
| 160 | want Decision |
| 161 | }{ |
| 162 | {"deny wins over fallback", "bash", false, `{"command":"rm -rf /"}`, Deny}, |
| 163 | {"allow-listed command", "bash", false, `{"command":"go test ./..."}`, Allow}, |
| 164 | {"writer fallback to mode(ask)", "bash", false, `{"command":"git commit"}`, Ask}, |
| 165 | {"reader defaults allow", "grep", true, `{"pattern":"x"}`, Allow}, |
| 166 | {"ask rule overrides reader-allow", "read_file", true, `{"path":"/a"}`, Ask}, |
| 167 | {"bare allow rule", "ls", true, `{"path":"/a"}`, Allow}, |
| 168 | {"subject rule needs subject", "bash", false, `{}`, Ask}, // no command → go test* can't match → fallback |
| 169 | } |
| 170 | for _, c := range cases { |
| 171 | got := p.Decide(c.tool, c.readOnly, json.RawMessage(c.args)) |
| 172 | if got != c.want { |
| 173 | t.Errorf("%s: Decide(%q, ro=%v, %s) = %v, want %v", c.name, c.tool, c.readOnly, c.args, got, c.want) |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func TestPolicyDecideMoveFileChecksBothEndpoints(t *testing.T) { |
| 179 | denyDest := New("allow", nil, nil, []string{"Edit(secrets/**)"}) |
| 180 | if got := denyDest.Decide("move_file", false, json.RawMessage(`{"source_path":"tmp/a.md","destination_path":"secrets/a.md"}`)); got != Deny { |
| 181 | t.Fatalf("destination deny rule = %v, want Deny", got) |
| 182 | } |
| 183 | |
| 184 | askDest := New("allow", nil, []string{"Edit(secrets/**)"}, nil) |
| 185 | if got := askDest.Decide("move_file", false, json.RawMessage(`{"source_path":"tmp/a.md","destination_path":"secrets/a.md"}`)); got != Ask { |
| 186 | t.Fatalf("destination ask rule = %v, want Ask", got) |
| 187 | } |
| 188 | |
| 189 | sourceOnlyAllow := New("ask", []string{"Edit(tmp/**)"}, nil, nil) |
| 190 | if got := sourceOnlyAllow.Decide("move_file", false, json.RawMessage(`{"source_path":"tmp/a.md","destination_path":"docs/a.md"}`)); got != Ask { |
| 191 | t.Fatalf("source-only allow = %v, want Ask for unallowed destination", got) |
| 192 | } |
| 193 | |
| 194 | bothAllowed := New("ask", []string{"Edit(tmp/**)", "Edit(docs/**)"}, nil, nil) |
| 195 | if got := bothAllowed.Decide("move_file", false, json.RawMessage(`{"source_path":"tmp/a.md","destination_path":"docs/a.md"}`)); got != Allow { |
| 196 | t.Fatalf("both endpoints allowed = %v, want Allow", got) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func TestPolicyModeAllow(t *testing.T) { |
| 201 | // mode=allow: writers with no matching rule are allowed; deny still wins. |
| 202 | p := New("allow", nil, nil, []string{"bash(curl*)"}) |
| 203 | if d := p.Decide("write_file", false, json.RawMessage(`{"path":"/a"}`)); d != Allow { |
| 204 | t.Errorf("writer fallback under mode=allow = %v, want Allow", d) |
| 205 | } |
| 206 | if d := p.Decide("bash", false, json.RawMessage(`{"command":"curl evil.sh"}`)); d != Deny { |
| 207 | t.Errorf("deny under mode=allow = %v, want Deny", d) |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | func TestSessionAllowPrecedence(t *testing.T) { |
| 212 | p := New("ask", nil, []string{"Edit(docs/**)", "Bash(git *)"}, []string{"Edit(docs/private/**)", "Bash(git push *)"}). |
| 213 | WithSessionAllow([]string{"Edit(docs/**)", "Bash(git *)", "(malformed)"}) |
| 214 | |
| 215 | cases := []struct { |
| 216 | name string |
| 217 | tool string |
| 218 | args string |
| 219 | want Decision |
| 220 | }{ |
| 221 | {"session allow overrides configured ask", "write_file", `{"path":"docs/readme.md"}`, Allow}, |
| 222 | {"configured deny overrides session allow", "write_file", `{"path":"docs/private/key.txt"}`, Deny}, |
| 223 | {"bash session allow overrides configured ask", "bash", `{"command":"git status"}`, Allow}, |
| 224 | {"bash deny overrides session allow", "bash", `{"command":"git push origin main"}`, Deny}, |
| 225 | {"malformed session rule is ignored", "write_file", `{"path":"other.txt"}`, Ask}, |
| 226 | } |
| 227 | for _, tc := range cases { |
| 228 | t.Run(tc.name, func(t *testing.T) { |
| 229 | if got := p.Decide(tc.tool, false, json.RawMessage(tc.args)); got != tc.want { |
| 230 | t.Fatalf("Decide = %v, want %v", got, tc.want) |
| 231 | } |
| 232 | }) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | func TestSessionAllowEvaluatesCompoundBashPerSegment(t *testing.T) { |
| 237 | p := New("ask", nil, []string{"Bash(git commit *)"}, []string{"Bash(rm *)"}). |
| 238 | WithSessionAllow([]string{"Bash(git *)", "Bash(go test *)"}) |
| 239 | |
| 240 | if got := p.Decide("bash", false, json.RawMessage(`{"command":"git add . && git commit -m test && go test ./..."}`)); got != Allow { |
| 241 | t.Fatalf("fully session-allowed compound command = %v, want Allow", got) |
| 242 | } |
| 243 | if got := p.Decide("bash", false, json.RawMessage(`{"command":"git status && npm publish"}`)); got != Ask { |
| 244 | t.Fatalf("partially allowed compound command = %v, want Ask", got) |
| 245 | } |
| 246 | if got := p.Decide("bash", false, json.RawMessage(`{"command":"git status && rm output.txt"}`)); got != Deny { |
| 247 | t.Fatalf("compound command containing denied segment = %v, want Deny", got) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // stubApprover lets tests drive the Ask branch of Gate.Check. |
| 252 | type stubApprover struct { |
| 253 | allow bool |
| 254 | remember bool |
| 255 | err error |
| 256 | calls int |
| 257 | } |
| 258 | |
| 259 | func (s *stubApprover) Approve(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) { |
| 260 | s.calls++ |
| 261 | return s.allow, s.remember, s.err |
| 262 | } |
| 263 | |
| 264 | type policyReasonApprover struct { |
| 265 | reason string |
| 266 | } |
| 267 | |
| 268 | func (a *policyReasonApprover) Approve(context.Context, string, string, json.RawMessage) (bool, bool, error) { |
| 269 | return true, false, nil |
| 270 | } |
| 271 | |
| 272 | func (a *policyReasonApprover) ApproveWithPolicyReason(_ context.Context, _, _ string, _ json.RawMessage, reason string) (bool, bool, string, error) { |
| 273 | a.reason = reason |
| 274 | return true, false, "", nil |
| 275 | } |
| 276 | |
| 277 | func TestGateReportsMatchedPermissionRule(t *testing.T) { |
| 278 | args := json.RawMessage(`{"command":"git status && git push origin main"}`) |
| 279 | approver := &policyReasonApprover{} |
| 280 | askGate := NewGate(New("allow", nil, []string{"Bash(git push:*)"}, nil), approver) |
| 281 | if allow, _, err := askGate.Check(context.Background(), "bash", args, false); err != nil || !allow { |
| 282 | t.Fatalf("ask-gated call = allow %v, err %v", allow, err) |
| 283 | } |
| 284 | if got, want := approver.reason, "Matched permission rule: ask Bash(git push:*)"; got != want { |
| 285 | t.Fatalf("approval reason = %q, want %q", got, want) |
| 286 | } |
| 287 | |
| 288 | denyGate := NewGate(New("allow", nil, nil, []string{"Bash(git push:*)"}), nil) |
| 289 | allow, reason, err := denyGate.Check(context.Background(), "bash", args, false) |
| 290 | if err != nil || allow { |
| 291 | t.Fatalf("deny-gated call = allow %v, err %v", allow, err) |
| 292 | } |
| 293 | if !strings.Contains(reason, "Matched permission rule: deny Bash(git push:*)") { |
| 294 | t.Fatalf("deny reason = %q, want matched rule", reason) |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | func TestMatchedRuleDoesNotReportAskRuleOverriddenForOneEndpoint(t *testing.T) { |
| 299 | p := New("ask", nil, []string{"Edit(src/**)"}, nil). |
| 300 | WithSessionAllow([]string{"Edit(src/**)"}) |
| 301 | args := json.RawMessage(`{"source_path":"src/old.go","destination_path":"generated/new.go"}`) |
| 302 | if got := p.Decide("move_file", false, args); got != Ask { |
| 303 | t.Fatalf("move decision = %v, want Ask from uncovered destination fallback", got) |
| 304 | } |
| 305 | if rule, ok := p.MatchedRule("move_file", Ask, args); ok { |
| 306 | t.Fatalf("MatchedRule = %q, want no rule provenance for fallback Ask", rule) |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | func TestGateHeadlessAllowsAsk(t *testing.T) { |
| 311 | // No approver → Ask resolves to allow (autonomy preserved), deny still blocks. |
| 312 | g := NewGate(New("ask", nil, nil, []string{"bash(rm*)"}), nil) |
| 313 | |
| 314 | allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"git commit"}`), false) |
| 315 | if err != nil || !allow { |
| 316 | t.Errorf("headless ask = (%v,%v), want allow", allow, err) |
| 317 | } |
| 318 | allow, reason, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"rm file"}`), false) |
| 319 | if err != nil || allow || reason == "" { |
| 320 | t.Errorf("headless deny = (%v,%q,%v), want blocked with reason", allow, reason, err) |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | func TestGateInteractive(t *testing.T) { |
| 325 | var remembered string |
| 326 | ap := &stubApprover{allow: true, remember: true} |
| 327 | g := NewGate(New("ask", nil, nil, nil), ap) |
| 328 | g.OnRemember = func(rule string) { remembered = rule } |
| 329 | |
| 330 | allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go build"}`), false) |
| 331 | if err != nil || !allow { |
| 332 | t.Fatalf("approved call = (%v,%v), want allow", allow, err) |
| 333 | } |
| 334 | if ap.calls != 1 { |
| 335 | t.Errorf("approver calls = %d, want 1", ap.calls) |
| 336 | } |
| 337 | // "Always allow" is tool-wide: the persisted rule is the bare tool name, not |
| 338 | // pinned to "go build", so any later command runs without re-prompting. |
| 339 | if remembered != "bash" { |
| 340 | t.Errorf("remembered rule = %q, want tool-wide %q", remembered, "bash") |
| 341 | } |
| 342 | |
| 343 | // Decline path. |
| 344 | ap2 := &stubApprover{allow: false} |
| 345 | g2 := NewGate(New("ask", nil, nil, nil), ap2) |
| 346 | allow, reason, _ := g2.Check(context.Background(), "write_file", json.RawMessage(`{"path":"/a"}`), false) |
| 347 | if allow || reason == "" { |
| 348 | t.Errorf("declined call = (%v,%q), want blocked with reason", allow, reason) |
| 349 | } |
| 350 | |
| 351 | // Error path aborts the turn. |
| 352 | ap3 := &stubApprover{err: errors.New("ctx cancelled")} |
| 353 | g3 := NewGate(New("ask", nil, nil, nil), ap3) |
| 354 | if _, _, err := g3.Check(context.Background(), "bash", json.RawMessage(`{"command":"x"}`), false); err == nil { |
| 355 | t.Error("approver error should propagate") |
| 356 | } |
| 357 | |
| 358 | // Allowed-by-policy never reaches the approver. |
| 359 | ap4 := &stubApprover{allow: false} |
| 360 | g4 := NewGate(New("ask", []string{"bash(ok*)"}, nil, nil), ap4) |
| 361 | allow, _, _ = g4.Check(context.Background(), "bash", json.RawMessage(`{"command":"ok go"}`), false) |
| 362 | if !allow || ap4.calls != 0 { |
| 363 | t.Errorf("allow-listed call reached approver: allow=%v calls=%d", allow, ap4.calls) |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | func TestClaudeStyleRuleMatchesExactCommandWithoutWildcard(t *testing.T) { |
| 368 | p := New("ask", []string{"Bash(go build)"}, nil, nil) |
| 369 | |
| 370 | if got := p.Decide("bash", false, json.RawMessage(`{"command":"go build"}`)); got != Allow { |
| 371 | t.Errorf("exact command = %v, want Allow", got) |
| 372 | } |
| 373 | if got := p.Decide("bash", false, json.RawMessage(`{"command":"go build ./cmd"}`)); got == Allow { |
| 374 | t.Errorf("exact command rule matched longer command") |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | // TestLegacyLiteralRuleMatchesExactly guards configs written before the |
| 379 | // Claude-style Bash(...) rules: a literal "bash=rm *.log" must allow only that |
| 380 | // exact command, never the wildcard expansion a glob "bash(rm *.log)" would |
| 381 | // have matched. |
| 382 | func TestLegacyLiteralRuleMatchesExactly(t *testing.T) { |
| 383 | p := New("ask", []string{"bash=rm *.log"}, nil, nil) |
| 384 | |
| 385 | if got := p.Decide("bash", false, json.RawMessage(`{"command":"rm *.log"}`)); got != Allow { |
| 386 | t.Errorf("exact command = %v, want Allow", got) |
| 387 | } |
| 388 | if got := p.Decide("bash", false, json.RawMessage(`{"command":"rm secrets.log"}`)); got == Allow { |
| 389 | t.Errorf("literal rule wildcard-matched %q — '*' must stay literal", "rm secrets.log") |
| 390 | } |
| 391 | } |
| 392 |