Module: Rubino::Security::PrefixDeriver
- Defined in:
- lib/rubino/security/prefix_deriver.rb
Overview
Derives the REUSABLE rule that an approval should be remembered as, instead of pinning memory to the exact “<tool>:<command>” string.
Mirrors the reference persistence unit: approve_session/is_approved key on a PATTERN KEY, not the raw command. For a dangerous command the pattern key IS the dangerous description, so approving it once covers the whole risk class for the session. For a plain command the reference allowlist is prefix-ish; we derive a leading-token prefix the same way CommandAllowlist matches (start_with?, command_allowlist.rb).
Pure derivation — no I/O, no persistence. Returns a small immutable Rule.
kind == :pattern -> remember a dangerous-pattern CLASS (value = key)
kind == :prefix -> remember a command PREFIX (value = "git")
kind == :command -> remember one EXACT command (value = "git status")
Defined Under Namespace
Classes: Rule
Constant Summary collapse
- WRAPPERS =
Wrapper commands whose first sub-token is part of the meaningful prefix (“bundle exec” / “npm run”), not the argument. Without this a naive “first token” prefix would collapse ‘bundle exec rspec` and `bundle install` into the same `bundle` rule.
{ "bundle" => %w[exec].freeze, "npm" => %w[run].freeze, "yarn" => %w[run].freeze, "pnpm" => %w[run].freeze, "rake" => [].freeze, "cargo" => %w[run].freeze }.freeze
Class Method Summary collapse
-
.command_prefix(command) ⇒ Object
Leading safe-token run of a plain command: “git status” -> “git status” “bundle exec rspec” -> “bundle exec” “npm run test –watch” -> “npm run” A wrapper command (bundle/npm/…) keeps its declared verb (exec/run) so distinct wrapped tools don’t collapse into one rule.
- .command_rule(tool:, command:) ⇒ Object
-
.git_prefix(tokens) ⇒ Object
The persistable prefix for a git command: ‘git <subcommand>` ONLY when the subcommand is provably read-only (no global flags before it, a read-only verb).
-
.narrow_rule_for(tool:, command:, pattern_key: nil) ⇒ Object
The NARROW rule used by :session / :always_command for S3 so behavior stays stable: a dangerous command remembers its pattern class (matching the reference), everything else remembers the exact command.
-
.plain_word?(token) ⇒ Boolean
A “plain word” is a bare token: not a flag, no path/assignment/glob punctuation — the shape that can safely extend a prefix.
-
.rule_for(tool:, command:, pattern_key: nil) ⇒ Object
Builds the rule a (tool, command) approval should be remembered as.
Class Method Details
.command_prefix(command) ⇒ Object
Leading safe-token run of a plain command:
"git status" -> "git status"
"bundle exec rspec" -> "bundle exec"
"npm run test --watch" -> "npm run"
A wrapper command (bundle/npm/…) keeps its declared verb (exec/run) so distinct wrapped tools don’t collapse into one rule. ‘git` keeps its READ-ONLY subcommand (`git status` not bare `git`) and refuses to derive a prefix at all for any non-read-only git verb: a bare `git` prefix persisted to the allowlist would pre-approve `git apply`, `git -c alias.x=!cmd x`, … = RCE/arbitrary-write (SEC-R2-1). For a plain command the head alone is the prefix. The run stops at the first flag or argument-shaped token, mirroring CommandAllowlist’s match.
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/rubino/security/prefix_deriver.rb', line 106 def command_prefix(command) tokens = command.to_s.strip.split(/\s+/) return "" if tokens.empty? head = tokens.first return git_prefix(tokens) if head == "git" prefix = [head] if WRAPPERS.key?(head) verb = tokens[1] prefix << verb if verb && plain_word?(verb) && WRAPPERS[head].include?(verb) end prefix.join(" ") end |
.command_rule(tool:, command:) ⇒ Object
88 89 90 91 92 |
# File 'lib/rubino/security/prefix_deriver.rb', line 88 def command_rule(tool:, command:) value = command.to_s.strip value = tool.to_s if value.empty? Rule.new(kind: :command, value: value) end |
.git_prefix(tokens) ⇒ Object
The persistable prefix for a git command: ‘git <subcommand>` ONLY when the subcommand is provably read-only (no global flags before it, a read-only verb). Anything else — a bare `git`, a global flag like `-c`, a mutating/code-loading verb (apply/am/push/…) — returns “” so the caller offers no “always_prefix” choice and the broad rule is never persisted. The narrow exact-command path still remains available.
128 129 130 131 132 133 134 |
# File 'lib/rubino/security/prefix_deriver.rb', line 128 def git_prefix(tokens) sub = tokens[1] return "" if sub.nil? || sub.start_with?("-") return "" unless Security::ReadonlyCommands::GIT_READONLY_SUBCOMMANDS.include?(sub) "git #{sub}" end |
.narrow_rule_for(tool:, command:, pattern_key: nil) ⇒ Object
The NARROW rule used by :session / :always_command for S3 so behavior stays stable: a dangerous command remembers its pattern class (matching the reference), everything else remembers the exact command. The broad :prefix rule is derivable via rule_for but only wired into a decision in S5.
80 81 82 83 84 85 86 |
# File 'lib/rubino/security/prefix_deriver.rb', line 80 def narrow_rule_for(tool:, command:, pattern_key: nil) cmd = command.to_s key = pattern_key || DangerousPatterns.detect(cmd)[1] return Rule.new(kind: :pattern, value: key) if key command_rule(tool: tool, command: cmd) end |
.plain_word?(token) ⇒ Boolean
A “plain word” is a bare token: not a flag, no path/assignment/glob punctuation — the shape that can safely extend a prefix.
138 139 140 |
# File 'lib/rubino/security/prefix_deriver.rb', line 138 def plain_word?(token) token.match?(/\A[A-Za-z0-9_:.-]+\z/) && !token.start_with?("-") end |
.rule_for(tool:, command:, pattern_key: nil) ⇒ Object
Builds the rule a (tool, command) approval should be remembered as.
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/rubino/security/prefix_deriver.rb', line 56 def rule_for(tool:, command:, pattern_key: nil) cmd = command.to_s key = pattern_key || DangerousPatterns.detect(cmd)[1] return Rule.new(kind: :pattern, value: key) if key # The :prefix rule ("allow `<head>` commands") only makes sense for the # shell tool, where sibling commands genuinely share a leading # executable (git status / git diff). For structured-arg tools the # "command" is a file path (write/edit/read) or a code/arg fragment # (ruby), so a derived prefix is nonsense — "allow `output.txt` # commands", "allow `6` commands". Remember those by exact command # instead, so the CLI/web offer no bogus prefix choice. (B6) return command_rule(tool: tool, command: cmd) unless tool.to_s == "shell" prefix = command_prefix(cmd) return command_rule(tool: tool, command: cmd) if prefix.empty? Rule.new(kind: :prefix, value: prefix) end |