Class: Rubino::Security::PatternMatcher

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/security/pattern_matcher.rb

Overview

Pattern-based permission matcher supporting wildcards. Matches tool names, commands, and file paths against configured rules.

Rules format in config:

permissions:
  "git *": "allow"
  "shell rm -rf *": "deny"
  "file_system write ~/.env": "deny"
  "shell bundle *": "allow"

Actions: “allow”, “ask”, “deny”

Constant Summary collapse

ACTIONS =
%w[allow ask deny].freeze

Instance Method Summary collapse

Constructor Details

#initialize(rules: {}) ⇒ PatternMatcher

Returns a new instance of PatternMatcher.



19
20
21
# File 'lib/rubino/security/pattern_matcher.rb', line 19

def initialize(rules: {})
  @rules = parse_rules(rules)
end

Instance Method Details

#match(tool_name, command_or_args = nil) ⇒ Object

Returns the action for a given tool call description Returns :allow, :ask, or :deny (nil when no rule matches).

DENY ALWAYS WINS (the documented permissions invariant). Resolution is NOT a plain first-hit on the specificity-sorted list — that let a longer, more specific :allow outrank a shorter overlapping :deny (e.g. “shell git push” => deny vs. “shell git push –force-with-lease …” => allow), silently swallowing the deny. Instead we resolve in two passes over ALL matching rules:

1. If ANY matching rule is :deny, the result is :deny — regardless of
   a longer overlapping :allow/:ask. (deny wins ACROSS verdict classes)
2. Otherwise the FIRST (= longest/most-specific, per #parse_rules)
   matching :allow/:ask wins. (longest-match preserved WITHIN a class)

The hardline floor is a separate, earlier layer (ApprovalPolicy step 1) and is unaffected.



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/rubino/security/pattern_matcher.rb', line 40

def match(tool_name, command_or_args = nil)
  full_string = [tool_name, command_or_args].compact.join(" ")

  first_non_deny = nil
  @rules.each do |pattern, action|
    next unless matches_pattern?(full_string, pattern)

    sym = action.to_sym
    # Pass 1: a deny short-circuits everything — deny always wins.
    return :deny if sym == :deny

    # Pass 2 (deferred): remember the first (most-specific) allow/ask, but
    # keep scanning in case a shorter overlapping deny is still ahead.
    first_non_deny ||= sym
  end

  # No matching deny: the longest/most-specific allow/ask (or nil).
  first_non_deny
end

#matches_pattern?(input, pattern) ⇒ Boolean

Returns true if the pattern matches the input

Returns:

  • (Boolean)


61
62
63
64
65
66
67
68
# File 'lib/rubino/security/pattern_matcher.rb', line 61

def matches_pattern?(input, pattern)
  # Convert glob-style pattern to regex
  regex_str = Regexp.escape(pattern)
                    .gsub('\*', ".*")
                    .gsub('\?', ".")
  regex = Regexp.new("\\A#{regex_str}\\z", Regexp::IGNORECASE)
  input.match?(regex)
end