Class: Rubino::Security::PatternMatcher
- Inherits:
-
Object
- Object
- Rubino::Security::PatternMatcher
- 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
-
#initialize(rules: {}) ⇒ PatternMatcher
constructor
A new instance of PatternMatcher.
-
#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).
-
#matches_pattern?(input, pattern) ⇒ Boolean
Returns true if the pattern matches the input.
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
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 |