Module: Rubino::Security::ReadonlyCommands
- Defined in:
- lib/rubino/security/readonly_commands.rb
Overview
Built-in auto-allow layer for provably READ-ONLY shell commands.
Sits at the same decision step as the user command allowlist (ApprovalPolicy step 6) — BELOW the hardline floor and permissions:deny, which always run first, and ABOVE the confirm-policy prompt. A command auto-allows ONLY when the ENTIRE line parses as safe:
- every chain segment (split on |, &&, ||, ;, newline) starts with a
command from the read-only set (or approvals.readonly_commands);
- no output redirection (>, >>, 2>; `tee` is simply not in the set),
no command substitution ($(...) or backticks, live contexts only —
single-quoted text is literal and stays allowed), no process
substitution (<(...), >(...)), no backgrounding (&);
- no leading variable assignments (FOO=bar cmd → prompt);
- no mutating flags on otherwise-safe heads (find -exec/-delete/...,
date -s, tree -o, git --output);
- git only with a read-only subcommand, conservatively flag-checked;
- no DangerousPatterns match on the whole line (defense-in-depth for
user-extended sets).
Anything the scanner cannot prove safe FAILS CLOSED to the normal approval prompt — never to silent execution. Pure functions, no I/O.
Constant Summary collapse
- SAFE_COMMANDS =
Read-only command heads auto-allowed by default. Conservative: each entry must be side-effect-free for ANY argument list once the flag checks below pass. ‘git` is handled separately (per-subcommand).
%w[ ls pwd find cat head tail grep rg wc file stat du df which whoami date tree echo ].freeze
- GIT_READONLY_SUBCOMMANDS =
git subcommands that never mutate the repository. ‘remote` is restricted further below (bare or -v only — `git remote add` mutates), `branch` to pure-flag listing forms (`git branch foo` CREATES a branch).
%w[status log diff show rev-parse blame].freeze
- GIT_BRANCH_READONLY_FLAGS =
%w[ -a -r -v -vv --list --all --remotes --show-current --verbose --merged --no-merged --color --no-color ].freeze
- FORBIDDEN_FLAGS =
Mutating/executing flags that disqualify an otherwise-safe head. Matched as exact token or ‘flag=value`.
{ "find" => %w[-exec -execdir -ok -okdir -delete -fprintf -fprint -fprint0 -fls], "date" => %w[-s --set], "tree" => %w[-o] }.freeze
- ASSIGNMENT_RE =
Leading ‘FOO=bar cmd` environment assignment — rejected, not stripped: an assignment can change what the command resolves to (PATH=…) or how it behaves, so it is never “provably read-only”.
/\A[A-Za-z_][A-Za-z0-9_]*=/
Class Method Summary collapse
-
.auto_allowed?(command, extra: []) ⇒ Boolean
True when the ENTIRE command line is provably read-only.
-
.consume_quoted(command, start, quote) ⇒ Object
Consumes the quoted region opening at ‘start`.
-
.extra_match?(tokens, extra) ⇒ Boolean
approvals.readonly_commands entries extend the built-in set: a bare name (“jq”) matches that head, a multi-word entry (“docker ps”) matches those leading tokens exactly.
-
.flush_segment(char, succ, segments, current) ⇒ Object
Flushes the segment ended by a chain operator and returns how many characters the operator consumes (2 for && and ||, 1 otherwise), or nil for a lone & — backgrounding is never provably read-only.
- .safe_flags?(head, tokens) ⇒ Boolean
-
.safe_git?(tokens) ⇒ Boolean
Read-only git: a safe subcommand (no global flags before it — ‘git -C` falls to the prompt), never –output (git log/diff/show can write a file with it), branch/remote in their pure listing forms only.
-
.safe_segment?(segment, extra: []) ⇒ Boolean
One pipeline segment: tokenize (Shellwords — a parse error rejects), refuse leading assignments, then require the head to be a safe command whose flags pass the per-command checks, or an ‘extra` config entry.
-
.split_segments(command) ⇒ Object
Splits a command line into chain segments (|, ||, &&, ;, newline), quote-aware.
Class Method Details
.auto_allowed?(command, extra: []) ⇒ Boolean
True when the ENTIRE command line is provably read-only. ‘extra` is the approvals.readonly_commands config: command names or leading-token prefixes (“jq”, “docker ps”) merged into the built-in set.
65 66 67 68 69 70 71 72 |
# File 'lib/rubino/security/readonly_commands.rb', line 65 def auto_allowed?(command, extra: []) return false if DangerousPatterns.dangerous?(command) segments = split_segments(command.to_s) return false if segments.nil? || segments.empty? segments.all? { |segment| safe_segment?(segment, extra: extra) } end |
.consume_quoted(command, start, quote) ⇒ Object
Consumes the quoted region opening at ‘start`. Returns the full substring including both quotes, or nil when the quote is unterminated or — for double quotes, where substitutions stay LIVE — when it contains $( or a backtick. Single-quoted text is literal in POSIX shells, so anything inside is safe to keep verbatim.
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/rubino/security/readonly_commands.rb', line 138 def consume_quoted(command, start, quote) i = start + 1 while i < command.length char = command[i] if quote == "\"" return nil if char == "`" || (char == "$" && command[i + 1] == "(") if char == "\\" i += 2 next end end return command[start..i] if char == quote i += 1 end nil end |
.extra_match?(tokens, extra) ⇒ Boolean
approvals.readonly_commands entries extend the built-in set: a bare name (“jq”) matches that head, a multi-word entry (“docker ps”) matches those leading tokens exactly.
203 204 205 206 207 208 |
# File 'lib/rubino/security/readonly_commands.rb', line 203 def extra_match?(tokens, extra) Array(extra).any? do |entry| entry_tokens = entry.to_s.strip.split(/\s+/) !entry_tokens.empty? && tokens.first(entry_tokens.length) == entry_tokens end end |
.flush_segment(char, succ, segments, current) ⇒ Object
Flushes the segment ended by a chain operator and returns how many characters the operator consumes (2 for && and ||, 1 otherwise), or nil for a lone & — backgrounding is never provably read-only.
126 127 128 129 130 131 |
# File 'lib/rubino/security/readonly_commands.rb', line 126 def flush_segment(char, succ, segments, current) return nil if char == "&" && succ != "&" segments << current "|&".include?(char) && succ == char ? 2 : 1 end |
.safe_flags?(head, tokens) ⇒ Boolean
173 174 175 176 177 178 179 180 |
# File 'lib/rubino/security/readonly_commands.rb', line 173 def safe_flags?(head, tokens) forbidden = FORBIDDEN_FLAGS[head] return true unless forbidden tokens.drop(1).none? do |token| forbidden.any? { |flag| token == flag || token.start_with?("#{flag}=") } end end |
.safe_git?(tokens) ⇒ Boolean
Read-only git: a safe subcommand (no global flags before it — ‘git -C` falls to the prompt), never –output (git log/diff/show can write a file with it), branch/remote in their pure listing forms only.
185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/rubino/security/readonly_commands.rb', line 185 def safe_git?(tokens) sub = tokens[1] return false if sub.nil? || sub.start_with?("-") rest = tokens.drop(2) return false if rest.any? { |t| t == "--output" || t.start_with?("--output=") } case sub when *GIT_READONLY_SUBCOMMANDS then true when "branch" then rest.all? { |t| GIT_BRANCH_READONLY_FLAGS.include?(t) } when "remote" then rest.empty? || rest == ["-v"] || rest == ["--verbose"] else false end end |
.safe_segment?(segment, extra: []) ⇒ Boolean
One pipeline segment: tokenize (Shellwords — a parse error rejects), refuse leading assignments, then require the head to be a safe command whose flags pass the per-command checks, or an ‘extra` config entry.
160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/rubino/security/readonly_commands.rb', line 160 def safe_segment?(segment, extra: []) tokens = Shellwords.split(segment) return false if tokens.empty? || tokens.first.match?(ASSIGNMENT_RE) head = tokens.first return safe_git?(tokens) if head == "git" return safe_flags?(head, tokens) if SAFE_COMMANDS.include?(head) extra_match?(tokens, extra) rescue ArgumentError false # unbalanced quotes etc. — fall through to the prompt end |
.split_segments(command) ⇒ Object
Splits a command line into chain segments (|, ||, &&, ;, newline), quote-aware. Returns nil — reject — on any construct that could smuggle a write or an execution: redirection (>), backgrounding (&), command substitution ($( or backtick in a live context), process substitution (<( / >( )), comments, trailing backslash, unterminated quotes. Plain ‘<` input redirection stays allowed. Single-quoted text is literal in POSIX shells, so substitutions inside it are safe to keep.
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/rubino/security/readonly_commands.rb', line 81 def split_segments(command) segments = [] current = +"" i = 0 while i < command.length char = command[i] succ = command[i + 1] case char when "'", "\"" quoted = consume_quoted(command, i, char) return nil unless quoted current << quoted i += quoted.length next when "\\" return nil if succ.nil? current << char << succ i += 1 when "`", ">", "#" return nil when "$", "<" return nil if succ == "(" current << char when ";", "\n", "|", "&" advance = flush_segment(char, succ, segments, current) return nil unless advance current = +"" i += advance next else current << char end i += 1 end segments << current segments.map(&:strip).reject(&:empty?) end |