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

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.

Returns:

  • (Boolean)


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.

Parameters:

  • pattern_key (String, nil) (defaults to: nil)

    the dangerous description when the caller has already detected one; we re-detect when absent so callers that only have the raw command still get a :pattern rule.



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