Class: Pikuri::Code::Bash::PassiveCommandDetector
- Inherits:
-
Object
- Object
- Pikuri::Code::Bash::PassiveCommandDetector
- 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:
- The first word is a bare command name — no
/../ls,/tmp/ls,bin/lsare paths that could point at a planted binary; only PATH-resolved bare names (+ls+,cat, …) qualify. - 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,falseare shell builtins, not PATH binaries; the classifier only string-matches the first token, so they slot in.cdis meaningful only inside a chain (+cd notes && ls+); a barecdis harmlessly passive. +true+/+false+ serve the ubiquitouscmd || trueidiom that swallows a probe's failure (+dpkg -S x || true+) — so thetruetail-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 =
systemctlsubcommands with no mutating form — the only invocations #passive_systemctl? accepts, and the verb must appear immediately aftersystemctl(so a leading--root=/xis 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 =
dpkgaction flags with no mutating form — must appear immediately afterdpkg(so a leading +--admindir+/+--root+ redirection is not passive).--get-selectionsis 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 =
aptsubcommands that only query the cache — must appear immediately afterapt(so a leading-o Foo=barconfig 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-cachesubcommands that only read the package cache — must appear immediately afterapt-cache(so a leading-o Dir::Cache=/x/ +-c file+ config injection is not passive). Excludesgencaches, which builds (writes) the cache — the one reasonapt-cachecan't sit in PASSIVE_BINARIES wholesale like its pure-query siblingdpkg-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 afternvme(so thenvme <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+/ thewrite*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+resetcommitmergepullpushconfiggc…). Set[ 'status', 'diff', 'log', 'show', 'blame', 'rev-parse', 'describe', 'shortlog' ].freeze
- DMESG_MUTATING_SHORT =
dmesgshort-option letters that mutate — buffer wipers (+-C+ clear,-cread-clear) and console controls (+-D+ off,-Eon,-nset-level). #passive_dmesg? scans each cluster char-by-char, so a bundled-xCtrips onC. Set['C', 'c', 'D', 'E', 'n'].freeze
- DMESG_MUTATING_LONG =
dmesglong options that mutate — the long spellings of DMESG_MUTATING_SHORT. #passive_dmesg? strips a=valuetail before lookup (+--console-level=1+ matches--console-level). Set[ '--clear', '--read-clear', '--console-off', '--console-on', '--console-level' ].freeze
- JOURNALCTL_MUTATING_LONG =
journalctllong 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=valuetail 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 =
manshort-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-aHtrips onH. Set['H', 'P'].freeze
- MAN_EXEC_LONG =
manlong options that spawn a program — the long spellings of MAN_EXEC_SHORT. #passive_man? strips a=valuetail 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 whyrgcan't sit besidegrepin PASSIVE_BINARIES. #passive_rg? strips a=valuetail 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 =
fileshort-option letters that write —-Ccompiles the magic file to a.mgcon disk. #passive_file? scans each cluster char-by-char, so a bundled-zCtrips onC. This is +file+'s only mutating form; every other flag (+-i+,-L,-s,-m magicfile…) reads. Set['C'].freeze
- FILE_WRITE_LONG =
filelong options that write — the long spelling of FILE_WRITE_SHORT. #passive_file? strips a=valuetail before lookup. Set['--compile'].freeze
- SMARTCTL_READ_SHORT =
smartctlshort-option letters that only read — SMART data (+-a+ all,-xxall,-Aattributes), device info (+-i+), health (+-H+), capabilities (+-c+), plus the device-type modifier-d(itsTYPEvalue 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-lsimply isn't listed and abstains (+-l selective,START-END+ sets a self-test span, which is why no-lis 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 =
smartctllong options that only read — the long spellings of SMARTCTL_READ_SHORT (+--device+ carries the-dmodifier). #passive_smartctl? strips a=valuetail before lookup (+--device=sat+ matches--device). Set[ '--all', '--xall', '--info', '--health', '--capabilities', '--attributes', '--device' ].freeze
Instance Method Summary collapse
-
#initialize(allow_git: false) ⇒ PassiveCommandDetector
constructor
A new instance of PassiveCommandDetector.
-
#passive?(command) ⇒ Boolean
Whether every Tokenizer.tokenize segment is a provably passive simple invocation.
Constructor Details
#initialize(allow_git: false) ⇒ PassiveCommandDetector
Returns a new instance of PassiveCommandDetector.
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.
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 |