esengine--deepseek-reasonix
669 行
21 KiB
Go
669 行
21 KiB
Go
// Package permission decides, per tool call, whether to allow it, deny it, or
|
|
// ask the user first. The core is a pure Policy (rule evaluation, no I/O); a
|
|
// Gate wraps a Policy with an optional interactive Approver and is what the
|
|
// agent consults at execute time. Keeping rule evaluation pure makes it
|
|
// trivially testable and keeps the agent independent of how "ask" is resolved.
|
|
package permission
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"reasonix/internal/shellparse"
|
|
)
|
|
|
|
// Decision is the outcome of evaluating a tool call against a Policy.
|
|
type Decision int
|
|
|
|
const (
|
|
// Allow runs the tool without prompting.
|
|
Allow Decision = iota
|
|
// Ask defers to an interactive Approver (or, with none, resolves to Allow).
|
|
Ask
|
|
// Deny blocks the tool in every mode.
|
|
Deny
|
|
)
|
|
|
|
func (d Decision) String() string {
|
|
switch d {
|
|
case Allow:
|
|
return "allow"
|
|
case Ask:
|
|
return "ask"
|
|
case Deny:
|
|
return "deny"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// ParseDecision maps a config string to a Decision. Unknown / empty input
|
|
// defaults to Ask — the conservative posture for a writer fallback.
|
|
func ParseDecision(s string) Decision {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case "allow":
|
|
return Allow
|
|
case "deny":
|
|
return Deny
|
|
default:
|
|
return Ask
|
|
}
|
|
}
|
|
|
|
// Rule matches tool calls. Tool is the tool name; Subject, when non-empty,
|
|
// constrains the call's subject. A glob Subject (see matchGlob) matches by
|
|
// wildcard; a Literal Subject matches by exact string equality. An empty Subject
|
|
// matches every call to Tool.
|
|
type Rule struct {
|
|
Tool string
|
|
Subject string
|
|
// Literal matches Subject by exact equality rather than as a glob, so a
|
|
// remembered concrete command keeps any '*'/'?' as ordinary characters
|
|
// instead of turning them into wildcards.
|
|
Literal bool
|
|
}
|
|
|
|
// ParseRule parses "ToolName", "ToolName(glob)", or the legacy
|
|
// "ToolName=literal" form. Surrounding whitespace is trimmed. The "=literal"
|
|
// form (taken when the '=' precedes any '(') matches the rest of the string
|
|
// verbatim — no globbing — and is kept for existing configs that were written
|
|
// before the Claude Code-style Tool(specifier) approval rules. ok is false for
|
|
// a malformed entry (empty tool name) so the caller can warn rather than
|
|
// silently install a rule that matches nothing.
|
|
func ParseRule(s string) (Rule, bool) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return Rule{}, false
|
|
}
|
|
if eq := strings.IndexByte(s, '='); eq > 0 {
|
|
if paren := strings.IndexByte(s, '('); paren < 0 || eq < paren {
|
|
tool := strings.TrimSpace(s[:eq])
|
|
if tool == "" {
|
|
return Rule{}, false
|
|
}
|
|
return Rule{Tool: tool, Subject: s[eq+1:], Literal: true}, true
|
|
}
|
|
}
|
|
if i := strings.IndexByte(s, '('); i >= 0 && strings.HasSuffix(s, ")") {
|
|
tool := strings.TrimSpace(s[:i])
|
|
if tool == "" {
|
|
return Rule{}, false
|
|
}
|
|
return Rule{Tool: tool, Subject: s[i+1 : len(s)-1]}, true
|
|
}
|
|
return Rule{Tool: s}, true
|
|
}
|
|
|
|
func parseRules(ss []string) []Rule {
|
|
var out []Rule
|
|
for _, s := range ss {
|
|
if r, ok := ParseRule(s); ok {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Policy is a set of rules plus the writer fallback mode. It is the pure,
|
|
// I/O-free heart of the permission layer.
|
|
type Policy struct {
|
|
// Mode is the fallback decision for writer tools when no rule matches.
|
|
// Read-only tools always fall back to Allow.
|
|
Mode Decision
|
|
Allow []Rule
|
|
Ask []Rule
|
|
Deny []Rule
|
|
}
|
|
|
|
// New builds a Policy from config string slices and a mode string ("ask" by
|
|
// default). Malformed rule strings are dropped.
|
|
func New(mode string, allow, ask, deny []string) Policy {
|
|
return Policy{
|
|
Mode: ParseDecision(mode),
|
|
Allow: parseRules(allow),
|
|
Ask: parseRules(ask),
|
|
Deny: parseRules(deny),
|
|
}
|
|
}
|
|
|
|
// Decide evaluates a tool call. readOnly is the tool's own classification; args
|
|
// is the raw JSON the model sent, from which the call's subject is extracted
|
|
// for glob matching. Calls with multiple subjects, such as move_file's source
|
|
// and destination paths, must be safe for every subject before the call is
|
|
// allowed. Precedence: deny > ask > allow > fallback (Allow for readers, Mode
|
|
// for writers).
|
|
func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision {
|
|
return p.DecideSubjects(toolName, readOnly, Subjects(args))
|
|
}
|
|
|
|
// DecideSubject evaluates a tool call when the caller already extracted the
|
|
// stable approval subject from args.
|
|
func (p Policy) DecideSubject(toolName string, readOnly bool, subject string) Decision {
|
|
if canonicalRuleTool(toolName) == "bash" {
|
|
switch {
|
|
case matchAny(p.Deny, toolName, subject):
|
|
return Deny
|
|
case matchAny(p.Ask, toolName, subject):
|
|
return Ask
|
|
case matchAny(p.Allow, toolName, subject):
|
|
return Allow
|
|
}
|
|
if parts := DecomposeBashCommand(subject); parts != nil {
|
|
return p.decideBashSegments(readOnly, parts)
|
|
}
|
|
}
|
|
switch {
|
|
case matchAny(p.Deny, toolName, subject):
|
|
return Deny
|
|
case matchAny(p.Ask, toolName, subject):
|
|
return Ask
|
|
case matchAny(p.Allow, toolName, subject):
|
|
return Allow
|
|
case readOnly:
|
|
return Allow
|
|
default:
|
|
return p.Mode
|
|
}
|
|
}
|
|
|
|
// decideBashSegments evaluates each simple-command segment of a compound bash
|
|
// invocation against the rule table independently. This lets prefix rules like
|
|
// `Bash(git push:*)` — created by the existing auto-save path for atomic
|
|
// commands — cover common compound flows (`git add . && git commit && git
|
|
// push`) without ever synthesizing a new prefix from a compound command.
|
|
//
|
|
// Precedence stays deny > ask > allow > fallback. Any single segment hitting
|
|
// deny denies the whole call; any segment needing approval turns the whole
|
|
// call into Ask; the whole call is Allow only if every segment is covered or
|
|
// writer fallback allows uncovered segments.
|
|
// A segment recognized as read-only by shellsafe (echo/ls/git status/...) is
|
|
// allowed on its own without a rule, matching the behavior of an atomic
|
|
// read-only bash call.
|
|
func (p Policy) decideBashSegments(readOnly bool, parts []string) Decision {
|
|
out := Allow
|
|
for _, sub := range parts {
|
|
segReadOnly := readOnly
|
|
if !segReadOnly {
|
|
if isReadOnlyBashSubject(sub) {
|
|
segReadOnly = true
|
|
}
|
|
}
|
|
switch {
|
|
case matchAny(p.Deny, "bash", sub):
|
|
return Deny
|
|
case matchAny(p.Ask, "bash", sub):
|
|
out = Ask
|
|
case matchAny(p.Allow, "bash", sub):
|
|
// covered
|
|
case segReadOnly:
|
|
// covered
|
|
default:
|
|
// Segment not covered by a rule or read-only classification: apply
|
|
// the same writer fallback used for atomic bash commands.
|
|
switch p.Mode {
|
|
case Deny:
|
|
return Deny
|
|
case Ask:
|
|
out = Ask
|
|
default:
|
|
// covered by fallback allow
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DecideSubjects evaluates a tool call against every subject the call touches.
|
|
// This keeps two-path operations honest: a move is denied if either endpoint is
|
|
// denied, asks if either endpoint requires approval, and is allowed only when
|
|
// every endpoint is allowed under the same policy.
|
|
func (p Policy) DecideSubjects(toolName string, readOnly bool, subjects []string) Decision {
|
|
if len(subjects) == 0 {
|
|
return p.DecideSubject(toolName, readOnly, "")
|
|
}
|
|
out := Allow
|
|
for _, subject := range subjects {
|
|
switch p.DecideSubject(toolName, readOnly, subject) {
|
|
case Deny:
|
|
return Deny
|
|
case Ask:
|
|
out = Ask
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// matchAny reports whether any rule matches the (toolName, subject) pair. A
|
|
// subject-specific rule cannot match a call that exposes no subject.
|
|
func matchAny(rules []Rule, toolName, subject string) bool {
|
|
for _, r := range rules {
|
|
if !ruleToolMatches(r.Tool, toolName) {
|
|
continue
|
|
}
|
|
if r.Subject == "" {
|
|
return true
|
|
}
|
|
if subject == "" {
|
|
continue
|
|
}
|
|
if ruleSubjectMatches(r, subject) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RuleMatchesString reports whether one config-style rule string matches the
|
|
// given tool subject. It is used for session grants as well as persisted config
|
|
// rules so both paths share identical matching semantics.
|
|
func RuleMatchesString(rule, toolName, subject string) bool {
|
|
r, ok := ParseRule(rule)
|
|
return ok && matchAny([]Rule{r}, toolName, subject)
|
|
}
|
|
|
|
// RuleCoversString reports whether every call represented by candidate is
|
|
// already covered by existing. It intentionally proves only the cases Reasonix
|
|
// creates automatically: exact rules covered by broader globs or bare tool
|
|
// rules, exact duplicate globs, and bare tool rules covering subject rules.
|
|
func RuleCoversString(existing, candidate string) bool {
|
|
a, ok := ParseRule(existing)
|
|
if !ok {
|
|
return false
|
|
}
|
|
b, ok := ParseRule(candidate)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if !ruleToolCompatible(a.Tool, b.Tool) {
|
|
return false
|
|
}
|
|
if a.Subject == "" {
|
|
return true
|
|
}
|
|
if b.Subject == "" {
|
|
return false
|
|
}
|
|
if bashRulePrefixBaseMatches(a, b) {
|
|
return true
|
|
}
|
|
if b.Literal || !hasGlobMeta(b.Subject) {
|
|
return ruleSubjectMatches(a, b.Subject)
|
|
}
|
|
return !a.Literal && a.Subject == b.Subject
|
|
}
|
|
|
|
func hasGlobMeta(s string) bool {
|
|
return strings.ContainsAny(s, "*?")
|
|
}
|
|
|
|
func bashRulePrefixBaseMatches(existing, candidate Rule) bool {
|
|
if canonicalRuleTool(existing.Tool) != "bash" || canonicalRuleTool(candidate.Tool) != "bash" {
|
|
return false
|
|
}
|
|
existingBase, ok := bashPrefixBase(existing.Subject)
|
|
if !ok {
|
|
return false
|
|
}
|
|
candidateBase, ok := bashPrefixBase(candidate.Subject)
|
|
return ok && existingBase == candidateBase
|
|
}
|
|
|
|
// subjectKeys are the JSON argument keys, in priority order, that carry a tool
|
|
// call's "subject" — the thing a Subject glob matches against. Generic so tools
|
|
// need not implement a permission-specific method: bash exposes command, the
|
|
// file tools expose path / file_path, grep & glob expose pattern.
|
|
var subjectKeys = []string{"command", "file_path", "path", "source_path", "destination_path", "pattern"}
|
|
|
|
// Subject extracts the primary matchable subject string from a call's raw JSON
|
|
// args, returning "" when none of the known keys is present (such a call only
|
|
// matches bare "ToolName" rules). Use Subjects for permission decisions that
|
|
// must account for every touched endpoint.
|
|
func Subject(args json.RawMessage) string {
|
|
subjects := Subjects(args)
|
|
if len(subjects) > 0 {
|
|
return subjects[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Subjects extracts every matchable subject from a call's raw JSON args. Most
|
|
// tools expose one subject; move_file exposes both source_path and
|
|
// destination_path so path-scoped permission rules can protect either endpoint.
|
|
func Subjects(args json.RawMessage) []string {
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(args, &m); err != nil {
|
|
return nil
|
|
}
|
|
src := stringArg(m, "source_path")
|
|
dst := stringArg(m, "destination_path")
|
|
if src != "" && dst != "" {
|
|
out := []string{src}
|
|
if dst != src {
|
|
out = append(out, dst)
|
|
}
|
|
return out
|
|
}
|
|
for _, k := range subjectKeys {
|
|
if s := stringArg(m, k); s != "" {
|
|
return []string{s}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stringArg(m map[string]any, key string) string {
|
|
if v, ok := m[key]; ok {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// matchGlob reports whether name matches pattern, where '*' matches any run of
|
|
// characters (including separators) and '?' matches exactly one. Unlike
|
|
// path.Match, '*' is not stopped by '/', which is what command-line and path
|
|
// prefixes ("rm -rf*", "/etc/*") intuitively expect. Linear time with
|
|
// backtracking, byte-oriented.
|
|
func matchGlob(pattern, name string) bool {
|
|
var px, nx, starPx, starNx int
|
|
starPx = -1
|
|
for nx < len(name) {
|
|
switch {
|
|
case px < len(pattern) && (pattern[px] == '?' || pattern[px] == name[nx]):
|
|
px++
|
|
nx++
|
|
case px < len(pattern) && pattern[px] == '*':
|
|
starPx = px
|
|
starNx = nx
|
|
px++
|
|
case starPx != -1:
|
|
px = starPx + 1
|
|
starNx++
|
|
nx = starNx
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
for px < len(pattern) && pattern[px] == '*' {
|
|
px++
|
|
}
|
|
return px == len(pattern)
|
|
}
|
|
|
|
// Approver resolves an Ask decision interactively. Implementations live in the
|
|
// front-end (the chat TUI); a non-interactive run passes a nil Approver, which
|
|
// the Gate treats as "allow" to preserve autonomous behaviour.
|
|
type Approver interface {
|
|
// Approve asks the user about a pending call. It returns whether to allow
|
|
// it and whether to remember that choice as a new rule. A non-nil err (e.g.
|
|
// the context was cancelled while waiting) aborts the turn.
|
|
Approve(ctx context.Context, toolName, subject string, args json.RawMessage) (allow, remember bool, err error)
|
|
}
|
|
|
|
// ReasonedApprover is the optional extension used by frontends that can return
|
|
// a denial reason to feed back to the model.
|
|
type ReasonedApprover interface {
|
|
ApproveWithReason(ctx context.Context, toolName, subject string, args json.RawMessage) (allow, remember bool, reason string, err error)
|
|
}
|
|
|
|
// Gate is what the agent consults at execute time: a Policy plus an optional
|
|
// Approver. It satisfies the agent's Gate interface structurally.
|
|
type Gate struct {
|
|
Policy Policy
|
|
Approver Approver
|
|
|
|
// OnRemember, when set, is invoked with a new allow rule the user chose to
|
|
// remember (e.g. "Bash(go build)"), so the front-end can persist it.
|
|
OnRemember func(rule string)
|
|
}
|
|
|
|
// NewGate wires a Policy to an Approver (nil for non-interactive use).
|
|
func NewGate(p Policy, a Approver) *Gate { return &Gate{Policy: p, Approver: a} }
|
|
|
|
// Check decides whether a tool call may run. It is the method the agent's Gate
|
|
// interface expects. A denied or refused call returns allow=false with a short
|
|
// reason the agent feeds back to the model.
|
|
func (g *Gate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
|
|
if toolName == "bash" && !readOnly {
|
|
subject := Subject(args)
|
|
if isReadOnlyBashSubject(subject) {
|
|
readOnly = true
|
|
}
|
|
}
|
|
switch g.Policy.Decide(toolName, readOnly, args) {
|
|
case Deny:
|
|
return false, "denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.", nil
|
|
case Ask:
|
|
if g.Approver == nil {
|
|
return true, "", nil // non-interactive: preserve autonomy
|
|
}
|
|
subject := Subject(args)
|
|
allow, remember, approverReason, err := g.approve(ctx, toolName, subject, args)
|
|
if err != nil {
|
|
return false, "approval aborted", err
|
|
}
|
|
if !allow {
|
|
reason := "the user declined this tool call — do not retry it; ask how they would like to proceed or choose another approach."
|
|
if approverReason != "" {
|
|
reason = approverReason
|
|
}
|
|
return false, reason, nil
|
|
}
|
|
if remember && g.OnRemember != nil {
|
|
// "Always allow" is tool-wide: persist the bare tool name so any
|
|
// later subject (a different file / command) is allowed without
|
|
// re-prompting. Deny rules still take precedence on every call.
|
|
g.OnRemember(toolName)
|
|
// Also add the rule to the in-memory Policy immediately so it
|
|
// takes effect in the current session without requiring a restart.
|
|
// The session-level grant (controller.granted) already covers the
|
|
// Approver path, but any code path that consults Policy.Decide()
|
|
// directly would miss the rule until the next controller build.
|
|
if rule, ok := ParseRule(toolName); ok {
|
|
g.Policy.Allow = append(g.Policy.Allow, rule)
|
|
}
|
|
}
|
|
return true, "", nil
|
|
default:
|
|
return true, "", nil
|
|
}
|
|
}
|
|
|
|
func (g *Gate) approve(ctx context.Context, toolName, subject string, args json.RawMessage) (bool, bool, string, error) {
|
|
if a, ok := g.Approver.(ReasonedApprover); ok {
|
|
return a.ApproveWithReason(ctx, toolName, subject, args)
|
|
}
|
|
allow, remember, err := g.Approver.Approve(ctx, toolName, subject, args)
|
|
return allow, remember, "", err
|
|
}
|
|
|
|
// rememberRule builds the rule string persisted when the user picks "always
|
|
// allow". Bash commands prefer a safe command prefix (e.g. go test:*) so
|
|
// "always allow" covers similar invocations with different arguments. File
|
|
// mutation tools are remembered tool-wide ("Edit") so approving one file edit
|
|
// covers all files. Other tools are remembered by tool name. Deny and ask rules keep their higher precedence.
|
|
func rememberRule(toolName, subject string) string {
|
|
return RememberRuleForScope(toolName, subject)
|
|
}
|
|
|
|
// RememberRuleForScope builds the rule string persisted when the user chooses
|
|
// an always-allow option. Bash commands prefer a safe prefix (go test:*) so
|
|
// similar invocations (different search terms, different test packages) match;
|
|
// when no safe prefix can be extracted the exact command is used. File
|
|
// mutation tools are always remembered tool-wide (Edit). Other tools use their
|
|
// bare tool name. Deny rules still take precedence on every call.
|
|
func RememberRuleForScope(toolName, subject string) string {
|
|
subject = strings.TrimSpace(subject)
|
|
if subject != "" && toolName == "bash" {
|
|
if pattern := BashCommandPrefix(subject); pattern != "" {
|
|
return "Bash(" + pattern + ")"
|
|
}
|
|
return "Bash(" + subject + ")"
|
|
}
|
|
if IsFileMutationTool(toolName) {
|
|
return "Edit"
|
|
}
|
|
return toolName
|
|
}
|
|
|
|
// SessionGrantKey returns the in-memory rule for "allow this session". Bash
|
|
// prefers a command prefix when one is available, falling back to the exact
|
|
// command when unsafe. File mutation tools share a single Edit grant.
|
|
func SessionGrantKey(toolName, subject string) string {
|
|
return SessionGrantRuleForScope(toolName, subject)
|
|
}
|
|
|
|
// SessionGrantRuleForScope returns the in-memory rule for a session grant.
|
|
// Bash prefers a command prefix when one is available; file mutation tools
|
|
// share a single Edit grant; all other tools return the bare tool name.
|
|
func SessionGrantRuleForScope(toolName, subject string) string {
|
|
subject = strings.TrimSpace(subject)
|
|
if toolName == "bash" && subject != "" {
|
|
if pattern := BashCommandPrefix(subject); pattern != "" {
|
|
return "Bash(" + pattern + ")"
|
|
}
|
|
return "Bash(" + subject + ")"
|
|
}
|
|
if IsFileMutationTool(toolName) {
|
|
return "Edit"
|
|
}
|
|
return toolName
|
|
}
|
|
|
|
// BashCommandPrefix returns a conservative prefix rule for "similar command"
|
|
// approvals. It avoids shell syntax and keeps the prefix at command-word
|
|
// boundaries, so approving "go test ./..." grants "go test:*" rather than a
|
|
// broader "go *".
|
|
func BashCommandPrefix(subject string) string {
|
|
cmd := strings.TrimSpace(subject)
|
|
if cmd == "" || containsShellSyntax(cmd) {
|
|
return ""
|
|
}
|
|
if BashDangerWarning(cmd) != "" {
|
|
return ""
|
|
}
|
|
fields, malformed := shellparse.StaticFields(cmd)
|
|
if malformed != "" {
|
|
return ""
|
|
}
|
|
if len(fields) < 2 {
|
|
return ""
|
|
}
|
|
base := strings.ToLower(fields[0])
|
|
if isPackageManagerRun(base) && len(fields) >= 3 && strings.ToLower(fields[1]) == "run" {
|
|
return fields[0] + " " + fields[1] + " " + fields[2] + ":*"
|
|
}
|
|
return fields[0] + " " + fields[1] + ":*"
|
|
}
|
|
|
|
func isPackageManagerRun(base string) bool {
|
|
switch base {
|
|
case "npm", "pnpm", "yarn", "bun":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// IsFileMutationTool reports whether a built-in tool mutates workspace files.
|
|
func IsFileMutationTool(toolName string) bool {
|
|
switch toolName {
|
|
case "write_file", "edit_file", "multi_edit", "move_file", "notebook_edit", "delete_range", "delete_symbol":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func ruleToolMatches(ruleTool, toolName string) bool {
|
|
ruleTool = canonicalRuleTool(ruleTool)
|
|
return ruleTool == toolName || (ruleTool == "file_mutation" && IsFileMutationTool(toolName))
|
|
}
|
|
|
|
func ruleToolCompatible(existingTool, candidateTool string) bool {
|
|
existingTool = canonicalRuleTool(existingTool)
|
|
candidateTool = canonicalRuleTool(candidateTool)
|
|
return existingTool == candidateTool ||
|
|
(existingTool == "file_mutation" && (candidateTool == "file_mutation" || IsFileMutationTool(candidateTool)))
|
|
}
|
|
|
|
func canonicalRuleTool(toolName string) string {
|
|
switch strings.TrimSpace(toolName) {
|
|
case "Bash", "bash":
|
|
return "bash"
|
|
case "Edit", "edit", "file_mutation":
|
|
return "file_mutation"
|
|
default:
|
|
return toolName
|
|
}
|
|
}
|
|
|
|
func ruleSubjectMatches(rule Rule, subject string) bool {
|
|
if rule.Subject == "" {
|
|
return true
|
|
}
|
|
if subject == "" {
|
|
return false
|
|
}
|
|
if rule.Literal {
|
|
return rule.Subject == subject
|
|
}
|
|
if canonicalRuleTool(rule.Tool) == "bash" {
|
|
if base, ok := bashColonPrefixBase(rule.Subject); ok {
|
|
return bashPrefixMatches(base, subject)
|
|
}
|
|
if base, ok := legacyBashSpaceStarPrefixBase(rule.Subject); ok {
|
|
return bashPrefixMatches(base, subject)
|
|
}
|
|
}
|
|
return matchGlob(rule.Subject, subject)
|
|
}
|
|
|
|
func bashColonPrefixBase(pattern string) (string, bool) {
|
|
if !strings.HasSuffix(pattern, ":*") {
|
|
return "", false
|
|
}
|
|
base := strings.TrimSuffix(pattern, ":*")
|
|
return base, base != ""
|
|
}
|
|
|
|
func legacyBashSpaceStarPrefixBase(pattern string) (string, bool) {
|
|
if !strings.HasSuffix(pattern, " *") {
|
|
return "", false
|
|
}
|
|
base := strings.TrimSuffix(pattern, " *")
|
|
return base, base != ""
|
|
}
|
|
|
|
func bashPrefixBase(pattern string) (string, bool) {
|
|
if base, ok := bashColonPrefixBase(pattern); ok {
|
|
return base, true
|
|
}
|
|
return legacyBashSpaceStarPrefixBase(pattern)
|
|
}
|
|
|
|
func bashPrefixMatches(base, subject string) bool {
|
|
if normalized, ok := normalizeBashSafeRedirectsForMatch(subject); ok {
|
|
subject = normalized
|
|
}
|
|
fields, malformed := shellparse.StaticFields(subject)
|
|
if malformed != "" {
|
|
return false
|
|
}
|
|
baseFields, malformed := shellparse.StaticFields(base)
|
|
if malformed != "" || len(baseFields) == 0 || len(fields) < len(baseFields) {
|
|
return false
|
|
}
|
|
for i, want := range baseFields {
|
|
if fields[i] != want {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|