Class: Pikuri::Code::Bash::PassiveCommandDetector

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/code/bash/passive_command_detector.rb

Overview

Classifies a bash command as passive: #passive? is true iff the command provably only observes state — it makes no change that persists (disk, device, remote) and sends nothing off the machine. Two clauses, both required: non-mutating and confined. cat foo is passive (the atime bump is below the line); rm mutates, git push and a curl GET escape the machine, smartctl -t perturbs the device — none are passive.

The verdict is a one-way proof: true means "provably passive", false only "not proven" — so the sole failure mode is asking the human when it needn't, never waving through something active. false is therefore not a rejection; the caller reads it as "ask a human" (see Wiring below).

Deliberately NOT a Workspace::Confirmer: it renders nothing and parses a command, where a confirmer must do neither.

The design rationale — why cutting pointless prompts is a security feature, what "passive" excludes vs. merely can't prove, the four recovery strategies and the residual each accepts, PATH-hijack, and the git-hardening containment behind allow_git: — lives in pikuri-code/DESIGN.md (Passive-command auto-approval). See also ideas/pikuri-os.md and ideas/bash-confirmer.md.

The decision rule (deny-by-default)

Tokenizer.tokenize parses the command into one word list per sequencer segment (+&&+/+||+/+;+/+|+/newline), or nil when it is more than such a simple chain (a metacharacter, unsafe redirect, unbalanced quote, too many segments) — in which case the command is not passive. On a successful parse the command is passive iff every segment is a provably-passive simple invocation:

  1. The first word is a bare command name — no /. ./ls, /tmp/ls, bin/ls are paths that could point at a planted binary; only PATH-resolved bare names (+ls+, cat, …) qualify.
  2. That bare name is on PASSIVE_BINARIES (or the segment is a passive +dpkg+/+apt+/+apt-cache+/+systemctl+/+file+/+dmesg+/ +journalctl+/+man+/+rg+/+nvme+/+smartctl+ query, or — under allow_git: — a passive git invocation). The four recovery strategies (subcommand gate / flag denylist / flag allowlist / glob fast path) are surveyed in DESIGN.md; each predicate below states its own.

A command the strict parse rejects solely because of an unquoted glob gets a second look through the #glob_passive? fast path (see there): globs are passive, but only on pure PASSIVE_BINARIES binaries, never the flag-classified families above.

Tokenizer hands the classifiers the true de-quoted word (+--clear+, not a --_clear placeholder), which is what makes the denylist / prefix checks sound — a placeholder rewrite would let a quote-smuggled --''clear slip past them.

Wiring

Bash.new(passive_detector: PassiveCommandDetector.new, …) (or .new(allow_git: true)). Pikuri::Code::Bash passes #passive? the raw command string; a non-command tool never consults this predicate, so there's no cross-tool coupling.

Constant Summary collapse

PASSIVE_BINARIES =

Binaries passive in every argument form (no mutating mode reachable, with or without root). See pikuri-code/DESIGN.md (Passive-command auto-approval) for the curation principle and exclusions.

cd, true, false are shell builtins, not PATH binaries; the classifier only string-matches the first token, so they slot in. cd is meaningful only inside a chain (+cd notes && ls+); a bare cd is harmlessly passive. +true+/+false+ serve the ubiquitous cmd || true idiom that swallows a probe's failure (+dpkg -S x || true+) — so the true tail-segment must classify passive for the whole chain to.

Set[
  'cd', 'true', 'false',
  'ls', 'cat', 'tac', 'nl', 'head', 'tail', 'wc',
  'grep', 'egrep', 'fgrep',
  'pwd', 'basename', 'dirname', 'realpath', 'readlink',
  'stat',
  'od', 'hexdump', 'strings',
  'cal',
  'uname', 'arch', 'nproc', 'lscpu', 'lsblk', 'lsusb', 'lspci',
  'df', 'du', 'free', 'vmstat', 'uptime', 'ps',
  'printenv', 'echo', 'printf',
  'whoami', 'id', 'groups',
  'which', 'type',
  'locale', 'getconf',
  'apropos', 'whatis',
  'dpkg-query'
].freeze
PASSIVE_SYSTEMCTL_SUBCOMMANDS =

systemctl subcommands with no mutating form — the only invocations #passive_systemctl? accepts, and the verb must appear immediately after systemctl (so a leading --root=/x is not passive). Excludes every state mutator (+start+/+stop+/+enable+/+mask+/ daemon-reload …). These only read; the pager (the one exec vector) spawns solely on a tty, which Pikuri::Code::Bash's piped stdout is not.

Set[
  'list-units', 'list-unit-files', 'list-timers', 'list-sockets',
  'list-jobs', 'list-dependencies', 'status', 'show', 'cat',
  'is-active', 'is-enabled', 'is-failed', 'get-default'
].freeze
PASSIVE_DPKG_ACTIONS =

dpkg action flags with no mutating form — must appear immediately after dpkg (so a leading +--admindir+/+--root+ redirection is not passive). --get-selections is the read-side sibling of the excluded --set-selections. Excludes every database mutator (+-i+/+-r+/+-P+/ +--unpack+/+--configure+/+--set-selections+ …).

Set[
  '-l', '--list', '-L', '--listfiles', '-s', '--status',
  '-S', '--search', '-p', '--print-avail', '--get-selections'
].freeze
PASSIVE_APT_SUBCOMMANDS =

apt subcommands that only query the cache — must appear immediately after apt (so a leading -o Foo=bar config injection is not passive). They run no pre/post-invoke hooks (those fire on the excluded mutating verbs +install+/+remove+/+update+/+upgrade+/…).

Set[
  'search', 'show', 'list', 'policy', 'depends', 'rdepends'
].freeze
PASSIVE_APT_CACHE_SUBCOMMANDS =

apt-cache subcommands that only read the package cache — must appear immediately after apt-cache (so a leading -o Dir::Cache=/x / +-c file+ config injection is not passive). Excludes gencaches, which builds (writes) the cache — the one reason apt-cache can't sit in PASSIVE_BINARIES wholesale like its pure-query sibling dpkg-query.

Set[
  'showpkg', 'showsrc', 'stats', 'dump', 'dumpavail', 'unmet',
  'search', 'show', 'depends', 'rdepends', 'pkgnames', 'policy',
  'madison', 'xvcg', 'dotty'
].freeze
PASSIVE_NVME_SUBCOMMANDS =

nvme (nvme-cli) subcommands with no mutating form — the only invocations #passive_nvme? accepts, and the verb must appear immediately after nvme (so the nvme <plugin> <verb> form, whose second word is a plugin name, is not passive). Excludes every mutator (+format+/+sanitize+/+set-feature+/+fw-download+/+fw-commit+/+reset+/ the write* family). Deliberately minimal — grow as real sessions surface further read verbs.

Set[
  'smart-log', 'list', 'id-ctrl', 'id-ns', 'error-log',
  'fw-log', 'get-log', 'get-feature'
].freeze
PASSIVE_GIT_SUBCOMMANDS =

git verbs with no mutating form — the only commands #passive_git? accepts (and only under allow_git:). Excludes +tag+/+branch+/ +remote+/+stash+/+reflog+ (each has a delete/create/expire form) and every working-tree / ref mutator (+checkout+ reset commit merge pull push config gc …).

Set[
  'status', 'diff', 'log', 'show', 'blame',
  'rev-parse', 'describe', 'shortlog'
].freeze
DMESG_MUTATING_SHORT =

dmesg short-option letters that mutate — buffer wipers (+-C+ clear, -c read-clear) and console controls (+-D+ off, -E on, -n set-level). #passive_dmesg? scans each cluster char-by-char, so a bundled -xC trips on C.

Set['C', 'c', 'D', 'E', 'n'].freeze
DMESG_MUTATING_LONG =

dmesg long options that mutate — the long spellings of DMESG_MUTATING_SHORT. #passive_dmesg? strips a =value tail before lookup (+--console-level=1+ matches --console-level).

Set[
  '--clear', '--read-clear', '--console-off', '--console-on',
  '--console-level'
].freeze
JOURNALCTL_MUTATING_LONG =

journalctl long options that mutate journal / FSS-key / catalog state — the space-maintenance ops (+--rotate+, --flush, --relinquish-var, --smart-relinquish-space, the --vacuum-* trimmers) and the on-disk writers (+--setup-keys+, --update-catalog). #passive_journalctl? strips a =value tail before lookup. There's no short companion set — every journalctl maintenance op is long-only.

Set[
  '--rotate', '--flush', '--relinquish-var',
  '--smart-relinquish-space',
  '--vacuum-size', '--vacuum-time', '--vacuum-files',
  '--setup-keys', '--update-catalog'
].freeze
MAN_EXEC_SHORT =

man short-option letters that spawn a program — -H (render to HTML and launch $BROWSER: exec and egress) and -P (run an arbitrary pager). #passive_man? scans each cluster char-by-char, so a bundled -aH trips on H.

Set['H', 'P'].freeze
MAN_EXEC_LONG =

man long options that spawn a program — the long spellings of MAN_EXEC_SHORT. #passive_man? strips a =value tail before lookup (+--pager=less+ matches --pager).

Set['--html', '--pager'].freeze
RG_EXEC_LONG =

rg (ripgrep) long options that run an arbitrary command — --pre (a preprocessor executed per searched file) and --hostname-bin (run to resolve the hostname for hyperlinks). Both are pure exec vectors, which is why rg can't sit beside grep in PASSIVE_BINARIES. #passive_rg? strips a =value tail before lookup (+--pre=/bin/sh+ matches --pre). rg has no short exec option, so a short cluster is passive data — no per-character scan needed (+-z+/+--search-zip+ shells only to fixed-name decompressors, already covered by the PATH-hijack residual, so it stays passive).

Set['--pre', '--hostname-bin'].freeze
FILE_WRITE_SHORT =

file short-option letters that write — -C compiles the magic file to a .mgc on disk. #passive_file? scans each cluster char-by-char, so a bundled -zC trips on C. This is +file+'s only mutating form; every other flag (+-i+, -L, -s, -m magicfile …) reads.

Set['C'].freeze
FILE_WRITE_LONG =

file long options that write — the long spelling of FILE_WRITE_SHORT. #passive_file? strips a =value tail before lookup.

Set['--compile'].freeze
SMARTCTL_READ_SHORT =

smartctl short-option letters that only read — SMART data (+-a+ all, -x xall, -A attributes), device info (+-i+), health (+-H+), capabilities (+-c+), plus the device-type modifier -d (its TYPE value is a separate token). #passive_smartctl? is passive iff every letter in a +-+-cluster is here — an allowlist, so a mutating letter (+-t+/+-s+/+-o+/+-S+/+-X+) or the value-sensitive -l simply isn't listed and abstains (+-l selective,START-END+ sets a self-test span, which is why no -l is classified).

No +GIT_HARDENING+-style companion is needed, unlike git: audited, the allowlisted forms run no configured command (+smartd.conf+ is the daemon's; +-B+/+--drivedb+ loads a data file and isn't allowlisted).

Set['a', 'x', 'i', 'H', 'c', 'A', 'd'].freeze
SMARTCTL_READ_LONG =

smartctl long options that only read — the long spellings of SMARTCTL_READ_SHORT (+--device+ carries the -d modifier). #passive_smartctl? strips a =value tail before lookup (+--device=sat+ matches --device).

Set[
  '--all', '--xall', '--info', '--health',
  '--capabilities', '--attributes', '--device'
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(allow_git: false) ⇒ PassiveCommandDetector

Returns a new instance of PassiveCommandDetector.

Parameters:

  • allow_git (Boolean) (defaults to: false)

    when true, additionally accept passive git invocations (#passive_git?). Default false. Sound only paired with GIT_HARDENING, which closes the core.fsmonitor vector; the caller accepts the narrow diff-driver residual (see pikuri-code/DESIGN.md).



249
250
251
# File 'lib/pikuri/code/bash/passive_command_detector.rb', line 249

def initialize(allow_git: false)
  @allow_git = allow_git
end

Instance Method Details

#passive?(command) ⇒ Boolean

Returns whether every Tokenizer.tokenize segment is a provably passive simple invocation. A nil parse (more than a simple chain) or a +nil+/empty command is not passive.

Parameters:

  • command (String, nil)

    the raw bash command string (as Pikuri::Code::Bash received it — no "$ " echo decoration).

Returns:

  • (Boolean)

    whether every Tokenizer.tokenize segment is a provably passive simple invocation. A nil parse (more than a simple chain) or a +nil+/empty command is not passive.



258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/pikuri/code/bash/passive_command_detector.rb', line 258

def passive?(command)
  return false if command.nil?

  command = command.strip
  return false if command.empty?

  segments = Tokenizer.tokenize(command)
  return segments.all? { |words| segment_passive?(words) } unless segments.nil?

  # The strict parse rejected it. If its *only* complication is an
  # unquoted glob, the glob fast path may still deem it passive.
  glob_passive?(command)
end