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_EXEC_OPTION_FLAGS =

Exec-capable git option FLAGS that, on ANY git command (including an otherwise read-only subcommand like ‘diff`/`log`), turn the invocation into arbitrary command execution by activating a repo-config driver. These run a `diff.<n>.command` / textconv program straight from the repository config WITHOUT any approval (#536: `git diff –ext-diff` live-created /tmp/PWNED via a poisoned diff driver). A read-only intent never needs them, so their presence disqualifies the auto-allow and the allowlist fast-path — the command still runs, but only AFTER approval. Matched as an exact token (and `flag=value` form, e.g. `–textconv=cmd`).

%w[
  --ext-diff --textconv -c --config-env --exec-path
  --git-dir --work-tree --namespace --attr-source -C
  --upload-pack --receive-pack -u
].freeze
GIT_EXEC_CONFIG_KEYS =

Config KEYS (used as the value of ‘-c key=val` / `–config-env key=env`, or anywhere a config name can appear) whose value git executes as a command: a poisoned one is RCE. Matched case-insensitively against any token via a substring scan so `-cdiff.external=…` (glued), `–config-env diff.external=…` and a bare `diff.external` all trip. `*` stands for the arbitrary middle segment of `diff.<n>.command`.

%w[
  diff.external core.pager core.sshcommand core.fsmonitor
  core.hookspath core.editor sequence.editor uploadpack.packobjectshook
  gpg.program ssh.variant
].freeze
GIT_EXEC_CONFIG_KEY_PATTERNS =

Config-key patterns with a wildcard middle segment (per-name drivers).

[
  /\bdiff\.[^=\s]+\.command\b/i,
  /\bdiff\.[^=\s]+\.textconv\b/i,
  /\bfilter\.[^=\s]+\.(?:clean|smudge|process)\b/i
].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
INLINE_CODE_FLAGS =

Heads that load/run a SCRIPT FILE by default (a coding agent runs these constantly — ‘python test.py`, `node build.js`, `bash script.sh`). The bare file-arg form is SAFE; only specific inline-code/eval/exec/write flags below turn them into arbitrary code (dangerous_flag_form?).

- `-c <code>`           python/bash/sh/zsh/ksh/dash run inline source
- `-e` / `-E` / `--eval` ALONE  perl/ruby/node bare-eval program
  (NOT `-pe`/`-ne`/`-pE`/`-nE` — those are stream READ filters, kept
  ALLOW: the danger is arbitrary code, not a line-by-line filter).
%w[-c -e -E --eval --exec].freeze
INLINE_CODE_FILTER_FLAGS =

‘-pe`/`-ne`/`-pE`/`-nE` perl/ruby filters: the `-e` rides a read mode, so the invocation is a stream filter, not a bare eval. Kept ALLOW.

%w[-pe -ne -pE -nE -ape -nle].freeze
FIND_EXEC_FLAGS =

The EXEC subset of FORBIDDEN_FLAGS: find’s program-running flags and ‘date -s` (system-clock mutation). find’s pure-write flags (‘-delete`, `-fprintf`, `-fprint`, `-fprint0`, `-fls`) and `sort -o`/`tree -o` are jail-contained writes and are NOT included.

%w[-exec -execdir -ok -okdir].freeze
GIT_EXEC_SUBCOMMANDS =

The EXEC/network git subcommands (run attacker code or touch the network), as opposed to the pure-write ‘–output` flag-form. Reuses the global-flag skipping of #dangerous_git? to find the subcommand token.

%w[
  apply am rebase merge cherry-pick revert checkout switch restore
  stash push pull fetch clone hook filter-branch send-email daemon
].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)


117
118
119
120
121
122
123
124
# File 'lib/rubino/security/readonly_commands.rb', line 117

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

.code_exec_eval_form?(head, tokens) ⇒ Boolean

The EXEC subset of #dangerous_code_exec_form?: inline-code/eval/exec forms that RUN arbitrary code. The pure-write forms (‘tee`, `sed -i`, `dd of=`) are dropped — the jail contains their writes. `tar –to-command`/`-O` pipe to a shell (exec) so they stay.

Returns:

  • (Boolean)


417
418
419
420
421
422
423
# File 'lib/rubino/security/readonly_commands.rb', line 417

def code_exec_eval_form?(head, tokens)
  args = tokens.drop(1)
  return true if head == "tar" && args.any? { |t| tar_exec_flag?(t) }
  return true if %w[xargs env eval].include?(head) && args.any? { |t| !t.start_with?("-") }

  args.any? { |t| inline_code_flag?(t) }
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.



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/rubino/security/readonly_commands.rb', line 242

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

.consume_redirect(command, at) ⇒ Object

Resolves a redirect at at (‘>` or `&>`): returns the char count to skip for a NON-WRITE redirect (`2>&1`, `>/dev/null`, `&>/dev/null`), :reject for a write-to-file redirect, or nil when there is NO redirect here (a chain operator the caller flushes instead).



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

def consume_redirect(command, at)
  redir = at                       # index of the `>`
  redir += 1 if command[at] == "&" # `&>` redirects both streams
  return nil unless command[redir] == ">"

  consumed = consume_safe_redirect(command, redir)
  return :reject unless consumed

  consumed + (redir - at)
end

.consume_safe_redirect(command, start) ⇒ Object

The redirect targets that perform NO arbitrary-file write: an fd duplication (‘>&1`, `>&2`) or a discard to the null device (`>/dev/null`). Anything else (`> out.txt`, `>> log`) writes a file and is rejected. Returns the number of chars consumed from `start` (the `>`), or nil to reject. `start` points at the FIRST `>` of the operator (a preceding fd digit like the `2` in `2>&1` is already in `current`, which is harmless — a bare `2` head fails the safe-command check anyway, and a real read-only head sits before it).



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/rubino/security/readonly_commands.rb', line 208

def consume_safe_redirect(command, start)
  # Skip the `>` (and a second `>` for the `>>` append form).
  i = start + 1
  i += 1 if command[i] == ">"
  rest = command[i..] || ""

  # fd duplication: `>&1`, `>&2`, `>&-`.
  return (i - start) + 2 if rest =~ /\A&[0-9-]/

  # Discard to the null device: `>/dev/null` (optionally with leading
  # whitespace, e.g. `> /dev/null`). The path must be EXACTLY /dev/null —
  # anchored so `/dev/nullx` (an arbitrary file) is NOT treated as the
  # device. A following redirect/chain/whitespace/EOL ends the token.
  m = rest.match(%r{\A\s*/dev/null(?=[\s;&|>]|\z)})
  return (i - start) + m.end(0) if m

  nil # writes an arbitrary file → reject
end

.dangerous_code_exec_form?(head, tokens) ⇒ Boolean

True when a CODE_EXEC_HEAD invocation carries an inline-code/eval/exec/ write flag (vs. running a plain script/file). Heads that ALWAYS write or pipe to a shell (‘tee`, `tar –to-command`, `dd of=`) are flagged on the head/operand; the rest need an explicit inline-code/eval flag.

Returns:

  • (Boolean)


450
451
452
453
454
455
456
457
458
459
# File 'lib/rubino/security/readonly_commands.rb', line 450

def dangerous_code_exec_form?(head, tokens)
  return true if head == "tee" # tee ALWAYS writes its operand

  args = tokens.drop(1)
  return true if head == "tar" && args.any? { |t| tar_exec_flag?(t) }
  return true if head == "dd"  && args.any? { |t| t.start_with?("of=") }
  return true if %w[xargs env eval].include?(head) && args.any? { |t| !t.start_with?("-") }

  args.any? { |t| inline_code_flag?(t) } || sed_in_place?(head, args)
end

.dangerous_flag_form?(tokens) ⇒ Boolean

The NARROW dangerous-flag-form screen used by the DEFAULT confirm gate (dangerous_only). UNLIKE the broad #dangerous_flags? (which rejects every CODE_EXEC_HEAD by its head, so even a bare ‘python test.py` / `sed ’s/a/b/‘` trips), this prompts ONLY for the genuinely dangerous WRITE/EXEC FLAG-FORMS and leaves ordinary script/filter invocations to auto-run. True ONLY for:

(a) git exec/config flag-forms (`-c`, `--config-env`, `--ext-diff`,
    `diff.external=…`, …) — reuse git_exec_vector?;
(b) git `--output`/`-o` (a write) — reuse git_write_flag?;
(c) FORBIDDEN_FLAGS heads carrying their write/exec flag (find
    -exec/-delete, sort -o/--output, date -s, tree -o);
(d) a CODE_EXEC_HEAD carrying an inline-code/eval/exec/write flag:
    `-c` (python/bash/sh/…), a lone `-e`/`-E`/`--eval` (perl/ruby/node),
    `sed -i`/`--in-place`, `tar --to-command`/`-O`, `tee` (always
    writes), `dd of=…`, `xargs`/`env`/`eval` running another command.

A CODE_EXEC_HEAD with ONLY a script/file arg, or a ‘-pe`/`-ne` read filter, is NOT flagged. A bare interpreter is NOT flagged. `tokens` is one already-split, non-chained segment.

Returns:

  • (Boolean)


355
356
357
358
359
360
361
362
363
364
# File 'lib/rubino/security/readonly_commands.rb', line 355

def dangerous_flag_form?(tokens)
  return false if tokens.empty?

  head = tokens.first
  return dangerous_git?(tokens) if head == "git"
  return !safe_flags?(head, tokens) if FORBIDDEN_FLAGS.key?(head)
  return dangerous_code_exec_form?(head, tokens) if CODE_EXEC_HEADS.include?(head)

  false
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)


312
313
314
315
316
317
318
319
320
321
322
# File 'lib/rubino/security/readonly_commands.rb', line 312

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)


518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# File 'lib/rubino/security/readonly_commands.rb', line 518

def dangerous_git?(tokens)
  # Exec-capable vectors (--ext-diff/--textconv/-c diff.external/…) are
  # dangerous wherever they appear — screen the whole line first (#536).
  return true if git_exec_vector?(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

.dangerous_git_exec_subcommand?(tokens) ⇒ Boolean

Returns:

  • (Boolean)


432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/rubino/security/readonly_commands.rb', line 432

def dangerous_git_exec_subcommand?(tokens)
  rest = tokens.drop(1)
  i = 0
  while i < rest.length
    tok = rest[i]
    break unless tok.start_with?("-")

    i += 1 if %w[-c -C].include?(tok) && !rest[i + 1].nil?
    i += 1
  end
  sub = rest[i]
  !sub.nil? && GIT_EXEC_SUBCOMMANDS.include?(sub)
end

.exec_flag_form?(tokens) ⇒ Boolean

The EXEC/network/system SUBSET of #dangerous_flag_form? — the forms that still warrant a prompt EVEN WHEN the OS write-jail is ACTIVE (slice 2 Part C). The jail confines arbitrary WRITES, so the pure-write flag-forms (‘sort -o`, `tree -o`, `find -delete`, `git –output`, `sed -i`, `dd of=`, `tee`, `tar` write/extract) no longer need a prompt once it is enforcing. But these still RUN ARBITRARY CODE (network exfil, reading secrets) or mutate the SYSTEM beyond the file jail, so the prompt stays a speed bump:

(a) git EXEC vectors (`-c`, `--config-env`, `--ext-diff`, `--textconv`,
    `core.sshCommand`, …) and the EXEC/NETWORK git subcommands
    (apply/am/hooks run attacker code; push/pull/fetch/clone/send-email
    touch the network) — git --output ALONE (a pure write) does NOT
    trip this;
(b) FORBIDDEN_FLAGS EXEC forms only: `find -exec`/`-execdir`/`-ok`/
    `-okdir` (run a program) and `date -s` (mutate the system clock).
    `find -delete`/`-fprintf`/… and `sort -o`/`tree -o` (pure writes)
    do NOT trip this;
(c) a CODE_EXEC_HEAD carrying an INLINE-CODE/EVAL/EXEC flag
    (`python -c`, `bash -c`, `perl -e`, `--eval`, `tar --to-command`,
    `xargs/env/eval CMD`) — `sed -i`/`tee`/`dd of=` (pure writes) do
    NOT trip this.

Conservative split: when in doubt a form is treated as EXEC (keeps prompting). ‘tokens` is one already-split, non-chained segment.

Returns:

  • (Boolean)


388
389
390
391
392
393
394
395
396
397
# File 'lib/rubino/security/readonly_commands.rb', line 388

def exec_flag_form?(tokens)
  return false if tokens.empty?

  head = tokens.first
  return git_exec_vector?(tokens) || dangerous_git_exec_subcommand?(tokens) if head == "git"
  return find_or_date_exec_form?(head, tokens) if FORBIDDEN_FLAGS.key?(head)
  return code_exec_eval_form?(head, tokens) if CODE_EXEC_HEADS.include?(head)

  false
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)


627
628
629
630
631
632
# File 'lib/rubino/security/readonly_commands.rb', line 627

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

.find_or_date_exec_form?(head, tokens) ⇒ Boolean

Returns:

  • (Boolean)


404
405
406
407
408
409
410
411
# File 'lib/rubino/security/readonly_commands.rb', line 404

def find_or_date_exec_form?(head, tokens)
  args = tokens.drop(1)
  case head
  when "find" then args.any? { |t| FIND_EXEC_FLAGS.include?(t) }
  when "date" then args.any? { |t| t == "-s" || t == "--set" || t.start_with?("--set=") }
  else false
  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.



230
231
232
233
234
235
# File 'lib/rubino/security/readonly_commands.rb', line 230

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

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

.git_exec_config_key?(tok) ⇒ Boolean

A token names (or carries as a value) a command-executing config key. Case-insensitive substring/pattern scan so the key trips whether it is a bare token, a ‘-ckey=val`/`key=val` form, or a `–config-env key=ENV`.

Returns:

  • (Boolean)


596
597
598
599
600
# File 'lib/rubino/security/readonly_commands.rb', line 596

def git_exec_config_key?(tok)
  low = tok.downcase
  GIT_EXEC_CONFIG_KEYS.any? { |k| low.include?(k) } ||
    GIT_EXEC_CONFIG_KEY_PATTERNS.any? { |re| re.match?(tok) }
end

.git_exec_option?(tok) ⇒ Boolean

A token is an exec-capable git OPTION when it is one of the exact flags (or its ‘flag=value` form), or a glued short form of `-c`/`-C`/`-u`.

Returns:

  • (Boolean)


585
586
587
588
589
590
591
# File 'lib/rubino/security/readonly_commands.rb', line 585

def git_exec_option?(tok)
  GIT_EXEC_OPTION_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_exec_vector?(tokens) ⇒ Boolean

True when ANY token in a git invocation is an exec-capable vector: a config-override flag (‘-c`/`–config-env`), an external-driver flag (`–ext-diff`/`–textconv`), a workspace/exec-path redirect, or a token naming a command-executing config key (`diff.external`, `core.pager`, `diff.<n>.command`, `filter.<n>.clean`, …). This is the Codex-model structural screen: a read-only git NEVER carries any of these, so their presence — anywhere in the line — disqualifies the silent auto-allow and the allowlist fast-path. Scans the WHOLE token list (global region AND post-subcommand args) so `git diff –ext-diff` and `git -c X=Y log` are both rejected (#536, GHSA-9ccr-r5hg-74gf).

Returns:

  • (Boolean)


579
580
581
# File 'lib/rubino/security/readonly_commands.rb', line 579

def git_exec_vector?(tokens)
  tokens.drop(1).any? { |tok| git_exec_option?(tok) || git_exec_config_key?(tok) }
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)


549
550
551
552
553
554
555
# File 'lib/rubino/security/readonly_commands.rb', line 549

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)


563
564
565
566
567
# File 'lib/rubino/security/readonly_commands.rb', line 563

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

.inline_code_flag?(token) ⇒ Boolean

An inline-code/eval/exec flag (‘-c`, lone `-e`/`-E`/`–eval`, `–exec`), excluding the `-pe`/`-ne` read-filter forms which stay ALLOW.

Returns:

  • (Boolean)


469
470
471
472
473
# File 'lib/rubino/security/readonly_commands.rb', line 469

def inline_code_flag?(token)
  return false if INLINE_CODE_FILTER_FLAGS.include?(token)

  INLINE_CODE_FLAGS.include?(token)
end

.safe_flags?(head, tokens) ⇒ Boolean

Returns:

  • (Boolean)


277
278
279
280
281
282
283
284
# File 'lib/rubino/security/readonly_commands.rb', line 277

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), never an exec-capable vector (–ext-diff/–textconv/-c diff.external/… run a repo-config driver, #536), branch/remote in their pure listing forms only.

Returns:

  • (Boolean)


607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
# File 'lib/rubino/security/readonly_commands.rb', line 607

def safe_git?(tokens)
  return false if git_exec_vector?(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)


264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/rubino/security/readonly_commands.rb', line 264

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

.sed_in_place?(head, args) ⇒ Boolean

‘sed -i` / `sed –in-place` (and the glued backup form `-i.bak`) edits the file in place — a write, so it is flagged.

Returns:

  • (Boolean)


477
478
479
480
481
# File 'lib/rubino/security/readonly_commands.rb', line 477

def sed_in_place?(head, args)
  return false unless head == "sed"

  args.any? { |t| t == "-i" || t.start_with?("-i.") || t == "--in-place" || t.start_with?("--in-place=") }
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: a file-writing redirection (‘> file`, `>> log`, `2> err.txt`), backgrounding (&), command substitution ($( or backtick in a live context), process substitution (<( / >( )), comments, trailing backslash, unterminated quotes. Plain `<` input redirection stays allowed. NON-WRITE redirects (fd-dup `2>&1`, discard-to-`/dev/null`) are consumed and DROPPED so a read-only command carrying them still auto-allows (#68) — the model habitually appends `2>&1`/`>/dev/null`. Single-quoted text is literal in POSIX shells, so substitutions inside it are safe to keep.



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/rubino/security/readonly_commands.rb', line 137

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 ">", "&", ";", "\n", "|"
      # A NON-WRITE redirect (`2>&1`, `>/dev/null`, `&>/dev/null`) is
      # dropped (segment kept); a chain operator (`;`/`\n`/`|`/`&&`) flushes
      # the segment; a write-to-file redirect or lone `&` rejects.
      redir = consume_redirect(command, i)
      return nil if redir == :reject

      unless redir # not a redirect → chain boundary
        redir = flush_segment(char, succ, segments, current) or return nil
        current = +""
      end
      i += redir
      next
    when "$", "<"
      return nil if succ == "("

      current << char
    else
      current << char
    end
    i += 1
  end
  segments << current
  segments.map(&:strip).reject(&:empty?)
end

.tar_exec_flag?(token) ⇒ Boolean

‘tar –to-command=PROG` pipes each member to a shell command, and `-O` extracts to stdout (used to pipe into a shell): both EXEC vectors.

Returns:

  • (Boolean)


463
464
465
# File 'lib/rubino/security/readonly_commands.rb', line 463

def tar_exec_flag?(token)
  token == "--to-command" || token.start_with?("--to-command=") || token == "-O"
end