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`. Heads that read by default but gain a WRITE or an EXEC through one of these flags: an allowlisted entry for the head pre-approves the read, never the smuggled write/exec.

sort -o FILE / --output=FILE         arbitrary write (SEC-R2-2)

(sed/tar/tee/awk/… are handled by CODE_EXEC_HEADS below — their argument is itself a program / they pipe to a shell, so no flag list can make an arbitrary-arg invocation safe.)

{
  "find" => %w[-exec -execdir -ok -okdir -delete -fprintf -fprint -fprint0 -fls],
  "date" => %w[-s --set],
  "tree" => %w[-o],
  "sort" => %w[-o --output]
}.freeze
CODE_EXEC_HEADS =

Heads whose very PURPOSE is to run arbitrary code (or whose argument is a program that can spawn a shell), so no flag inspection can make an arbitrary-arg invocation provably safe. An allowlist entry for one of these is DENIED auto-approval outright (default-deny): being on the allowlist must never pre-approve ‘awk ’BEGINsystem(“…”)‘`, `sed -n ’…e cmd’‘, `perl -e …`, `tee FILE`, etc. (SEC-R2-2). This is the convenience-layer floor; these still run after an explicit prompt.

%w[
  awk gawk mawk sed perl python python2 python3 ruby node php pwsh
  bash sh zsh ksh dash env xargs eval source tee tar dd
].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_]*=/
FLAG_VETTED_HEADS =

Heads that are otherwise allowlistable but can still WRITE or EXEC through trailing flags (git –output, find -exec/-delete/-fprintf, date -s, tree -o, sort -o, tar –to-command). The user command allowlist reuses this to vet the flags of a matched entry, so an allowlisted head (e.g. ‘git diff`) can never smuggle an arbitrary write via `–output`.

(["git"] + FORBIDDEN_FLAGS.keys).freeze
GIT_GLOBAL_EXEC_FLAGS =

Git GLOBAL flags (between ‘git` and the subcommand) that load or run arbitrary code, and the dangerous subcommands an allowlisted bare `git` would otherwise pre-approve. None of these belong to a read-only git intent, so an allowlisted git head carrying any of them is rejected.

-c <name>=<val> / -c<name>=<val>   sets config for this invocation; the
  load-bearing ones are alias.* (a `!cmd` alias = RCE), core.sshCommand,
  core.pager, core.editor, core.hooksPath, core.fsmonitor,
  uploadpack/receivepack.* — all run a command. We reject -c entirely
  for an auto-approved git: a read-only git never needs per-call config.
--config-env=<name>=<envvar>       sets config like -c, sourcing the
  VALUE from an environment variable (so `--config-env=alias.x=PWNVAR`
  with PWNVAR='!cmd' is the same RCE as `-c alias.x='!cmd'`). Rejected
  for the same reason as -c (SEC-R3-1).
--attr-source=<tree>               reads .gitattributes from an
  arbitrary tree-ish; not config, but never part of a read-only intent,
  so rejected too (SEC-R3-1).
-C <path> / --exec-path[=path]     changes the working dir / git's exec
  path (which can point git at attacker binaries).
%w[
  -c --config-env --exec-path --git-dir --work-tree --namespace --attr-source -C
].freeze
GIT_DANGEROUS_SUBCOMMANDS =

Subcommands that mutate the working tree / apply attacker-supplied data / run hooks, never part of a read-only intent. An allowlisted git head must not pre-approve them even though they don’t start with ‘-`.

%w[
  apply am rebase merge cherry-pick revert reset checkout switch restore
  clean stash push pull fetch clone commit hook filter-branch
  update-index update-ref symbolic-ref config send-email daemon
].freeze

Class Method Summary collapse

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.

Returns:

  • (Boolean)


84
85
86
87
88
89
90
91
# File 'lib/rubino/security/readonly_commands.rb', line 84

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.



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/rubino/security/readonly_commands.rb', line 157

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

.dangerous_flags?(tokens) ⇒ Boolean

True when ‘tokens` (a single already-split, non-chained segment whose head matched a user allowlist entry) must NOT be auto-approved because the head can WRITE or EXEC arbitrary code with these arguments. An allowlist entry pre-approves the EXACT read-only intent of a head, never a smuggled write/exec form. Pure inspection; it does NOT require the command to be read-only overall (an allowlist entry is user-chosen), it only rejects the forms that turn a read into a write/exec.

- CODE_EXEC_HEADS (awk/sed/perl/python/tar/tee/xargs/...) are
  default-denied outright: their argument IS a program (or pipes to a
  shell), so `awk 'BEGIN{system("…")}'`, `sed -e '…'`, `tar
  --to-command=sh` can't be made provably safe by flag inspection
  (SEC-R2-2);
- git is screened for BOTH global flags before the subcommand
  (`-c alias.X=!cmd`, `-c core.sshCommand=…`, `-C dir`, `--exec-path`)
  and a code-loading/writing subcommand (`apply`, `am`, hooks, …) and
  the --output/-o write flag (SEC-R2-1);
- the remaining FORBIDDEN_FLAGS heads (find/date/tree/sort/...) are
  screened for their specific write/exec flags.

Returns:

  • (Boolean)


227
228
229
230
231
232
233
234
235
236
237
# File 'lib/rubino/security/readonly_commands.rb', line 227

def dangerous_flags?(tokens)
  head = tokens.first
  return true if CODE_EXEC_HEADS.include?(head)
  return false unless FLAG_VETTED_HEADS.include?(head)

  if head == "git"
    dangerous_git?(tokens)
  else
    !safe_flags?(head, tokens)
  end
end

.dangerous_git?(tokens) ⇒ Boolean

True when a git invocation whose head matched an allowlist entry carries a code-loading global flag, a dangerous subcommand, or an output-writing flag. Scans the GLOBAL flag region (before the subcommand) AND the rest.

Returns:

  • (Boolean)


274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/rubino/security/readonly_commands.rb', line 274

def dangerous_git?(tokens)
  rest = tokens.drop(1)
  # Global flag region: everything up to the first non-flag token (the
  # subcommand). `-c name=val` / `-C path` may consume the next token as
  # their value, so a value that happens to look like a subcommand isn't
  # mistaken for one.
  i = 0
  while i < rest.length
    tok = rest[i]
    break unless tok.start_with?("-")

    return true if git_global_exec_flag?(tok)

    # -c / -C / --exec-path take a value as the NEXT token when not glued.
    i += 1 if %w[-c -C].include?(tok) && !rest[i + 1].nil?
    i += 1
  end

  sub = rest[i]
  return true if sub && GIT_DANGEROUS_SUBCOMMANDS.include?(sub)

  git_write_flag?(rest.drop(i + 1))
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.

Returns:

  • (Boolean)


343
344
345
346
347
348
# File 'lib/rubino/security/readonly_commands.rb', line 343

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.



145
146
147
148
149
150
# File 'lib/rubino/security/readonly_commands.rb', line 145

def flush_segment(char, succ, segments, current)
  return nil if char == "&" && succ != "&"

  segments << current
  "|&".include?(char) && succ == char ? 2 : 1
end

.git_global_exec_flag?(tok) ⇒ Boolean

A global-flag token matches when it is the exact flag (‘-c`, `–exec-path`), its `flag=value` form (`–exec-path=/x`), or a glued short form (`-cNAME=VAL`, `-Cpath`).

Returns:

  • (Boolean)


301
302
303
304
305
306
307
# File 'lib/rubino/security/readonly_commands.rb', line 301

def git_global_exec_flag?(tok)
  GIT_GLOBAL_EXEC_FLAGS.any? do |f|
    tok == f ||
      tok.start_with?("#{f}=") ||
      (f.length == 2 && f.start_with?("-") && !f.start_with?("--") && tok.start_with?(f) && tok.length > 2)
  end
end

.git_write_flag?(rest) ⇒ Boolean

Git flags that write the output to an arbitrary file:

--output <file> / --output=<file>  (git diff/log/show/format-patch)
-o <file> / -o<file>               (short form, git log/format-patch)

‘-O<orderfile>` reads an orderfile (no write) but is rejected too, so a short-flag write form can never slip through ambiguity. Matched on the whole rest of the segment (token or `flag=value` / glued `-oFILE`).

Returns:

  • (Boolean)


315
316
317
318
319
# File 'lib/rubino/security/readonly_commands.rb', line 315

def git_write_flag?(rest)
  rest.any? do |t|
    t == "--output" || t.start_with?("--output=", "-o", "-O")
  end
end

.safe_flags?(head, tokens) ⇒ Boolean

Returns:

  • (Boolean)


192
193
194
195
196
197
198
199
# File 'lib/rubino/security/readonly_commands.rb', line 192

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 an output-writing flag (git log/diff/show can write a file with –output/-o), branch/remote in their pure listing forms only.

Returns:

  • (Boolean)


325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/rubino/security/readonly_commands.rb', line 325

def safe_git?(tokens)
  sub = tokens[1]
  return false if sub.nil? || sub.start_with?("-")

  rest = tokens.drop(2)
  return false if git_write_flag?(rest)

  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.

Returns:

  • (Boolean)


179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/rubino/security/readonly_commands.rb', line 179

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.



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/rubino/security/readonly_commands.rb', line 100

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