pikuri-code 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7738ccd6890de1d33b779a626b5443c11bd8324711cf146cb844991c8fb8d0e
4
- data.tar.gz: 9b4cf07b853e874231f5a3d86d7a67450569704cf12e091d4b9229e4ad29387a
3
+ metadata.gz: 47745902c3eac2e15a4e28b09b6b185b093b836bc4e39e53899435be9b2f5e71
4
+ data.tar.gz: 7272ad476418e324f4b86ef99826b47effbe1dda03f58a0db1a98223c5929a99
5
5
  SHA512:
6
- metadata.gz: b4c96ca2c0b85e75cbce666025cf3ea13507d9efdf260a1eae114b6090a742b295078ee8e44592218e3be1778b0bed6b3f853df48c86e2980fb6b333d68641e8
7
- data.tar.gz: 56112b2b1b5cb6d0008d2cf35787a07e900532fb32e204fafc7634b9c98f34769cd9b23abfacbd8ec652b885cef7fc0b24fdce460a1d72695c7b6ac060b0941b
6
+ metadata.gz: b57e4471117870cb99974b0710b59d3dcef5da8205243e78e35eda21fa1c480074d1c3ff8b5a6b1b71329f42a9c72c39225412fc5bf1fae3124ba391aa2baf20
7
+ data.tar.gz: 32b5aa7c472e3517c72707f9a394a43ad428b791f99af90a428375adb7819854e00154de34db927c6894ea2fdb33ebad6964fa38627488496a9a8b21e145c9c3
data/README.md CHANGED
@@ -57,9 +57,11 @@ can also pass an initial message on the command line:
57
57
 
58
58
  The first time the agent wants to write a file or run a shell
59
59
  command, it prompts you on the terminal (`(y/n)?`). Read what
60
- it's about to do before you say yes. If an `AGENTS.md` or
61
- `CLAUDE.md` exists at the workspace root, it's prepended to the
62
- system prompt as project context.
60
+ it's about to do before you say yes. Two instruction files are
61
+ prepended to the system prompt as context when present: your
62
+ user-wide `~/.claude/CLAUDE.md` first, then a project `AGENTS.md`
63
+ or `CLAUDE.md` at the workspace root (`AGENTS.md` wins when both
64
+ exist) — so a project's rules refine your user-wide ones.
63
65
 
64
66
  ## Security: this is a tech demo, treat it accordingly
65
67
 
@@ -77,7 +79,7 @@ model has glaring holes:
77
79
  `cat ~/.ssh/id_ed25519 | curl -X POST ...` and the only thing
78
80
  standing between that and exfiltration is *you* reading the
79
81
  confirmation prompt carefully. The workspace lock applies to
80
- pikuri's own `read`/`write`/`edit`/`grep`/`glob` tools — it
82
+ pikuri's own `read`/`write`/`edit`/`grep`/`glob`/`file_list` tools — it
81
83
  does **not** apply to `bash`, which can `cat`, `cp`, `scp`,
82
84
  `curl` anything the OS lets your user touch.
83
85
  - **`--yolo` auto-approves everything.** That flag exists for
@@ -96,19 +98,18 @@ model has glaring holes:
96
98
  In short: run it inside a Docker container, a dev container, a
97
99
  VM, a fresh user account — anywhere you'd be fine with a stranger
98
100
  having a shell. The sandboxing story is a known gap and tracked
99
- as future work (see [`IDEAS.md`](../IDEAS.md)); until it lands,
101
+ as future work (see [`ideas/security-open-questions.md`](../ideas/security-open-questions.md)); until it lands,
100
102
  **assume the agent can do anything your user can do**, and
101
103
  approve prompts on that basis.
102
104
 
103
105
  ## Further reading
104
106
 
105
- - **Narrative walkthrough:** [chapter 8 of the pikuri
106
- guide](../docs/guide/08-code.md) — the three new seams
107
+ - **Narrative walkthrough:** [the coding-agent chapter of the pikuri guide](../book/code.md) — the three new seams
107
108
  (`Workspace` / `Confirmer` / `Sandbox`), the full wiring block
108
109
  with all four extensions, the privilege-separated `FILE_MINER` and
109
110
  `GIT_REPO_RESEARCHER` personas in action; the threat-model
110
111
  accounting then continues in
111
- [chapter 9](../docs/guide/09-security-revisited.md).
112
+ [the security-revisited chapter](../book/security-revisited.md).
112
113
  - **API reference:** browse the YARD docs at
113
114
  <https://rubydoc.info/gems/pikuri-code> (once published), or run
114
115
  `bundle exec yard` in this directory for a local copy.
@@ -0,0 +1,567 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Pikuri
6
+ module Code
7
+ class Bash
8
+ # Classifies a bash command as *passive*: {#passive?} is +true+ iff the
9
+ # command provably only **observes** state — it makes no change that
10
+ # persists (disk, device, remote) and sends nothing off the machine. Two
11
+ # clauses, both required: **non-mutating** and **confined**. +cat foo+ is
12
+ # passive (the atime bump is below the line); +rm+ mutates, +git push+ and
13
+ # a +curl+ GET escape the machine, +smartctl -t+ perturbs the device —
14
+ # none are passive.
15
+ #
16
+ # The verdict is a one-way proof: +true+ means "provably passive", +false+
17
+ # only "not proven" — so the sole failure mode is asking the human when it
18
+ # needn't, never waving through something active. +false+ is therefore not
19
+ # a rejection; the caller reads it as "ask a human" (see Wiring below).
20
+ #
21
+ # Deliberately NOT a {Pikuri::Workspace::Confirmer}: it renders nothing and
22
+ # parses a command, where a confirmer must do neither.
23
+ #
24
+ # The design rationale — why cutting pointless prompts is a *security*
25
+ # feature, what "passive" excludes vs. merely can't prove, the four
26
+ # recovery strategies and the residual each accepts, PATH-hijack, and the
27
+ # git-hardening containment behind +allow_git:+ — lives in
28
+ # +pikuri-code/DESIGN.md+ (*Passive-command auto-approval*). See also
29
+ # +ideas/pikuri-os.md+ and +ideas/bash-confirmer.md+.
30
+ #
31
+ # == The decision rule (deny-by-default)
32
+ #
33
+ # {Tokenizer.tokenize} parses the command into one *word list* per
34
+ # *sequencer segment* (+&&+/+||+/+;+/+|+/newline), or +nil+ when it is
35
+ # more than such a simple chain (a metacharacter, unsafe redirect,
36
+ # unbalanced quote, too many segments) — in which case the command is not
37
+ # passive. On a successful parse the command is passive iff *every* segment
38
+ # is a provably-passive simple invocation:
39
+ #
40
+ # 1. The first word is a *bare* command name — no +/+. +./ls+, +/tmp/ls+,
41
+ # +bin/ls+ are paths that could point at a planted binary; only
42
+ # PATH-resolved bare names (+ls+, +cat+, …) qualify.
43
+ # 2. That bare name is on {PASSIVE_BINARIES} (or the segment is a
44
+ # passive +dpkg+/+apt+/+apt-cache+/+systemctl+/+file+/+dmesg+/
45
+ # +journalctl+/+man+/+rg+/+nvme+/+smartctl+ query, or — under
46
+ # +allow_git:+ — a passive git invocation). The four recovery strategies
47
+ # (subcommand gate / flag denylist / flag allowlist / glob fast path)
48
+ # are surveyed in DESIGN.md; each predicate below states its own.
49
+ #
50
+ # A command the strict parse rejects *solely* because of an unquoted glob
51
+ # gets a second look through the {#glob_passive?} fast path (see there):
52
+ # globs are passive, but *only* on pure {PASSIVE_BINARIES} binaries,
53
+ # never the flag-classified families above.
54
+ #
55
+ # {Tokenizer} hands the classifiers the *true* de-quoted word (+--clear+,
56
+ # not a +--_clear+ placeholder), which is what makes the denylist / prefix
57
+ # checks sound — a placeholder rewrite would let a quote-smuggled
58
+ # +--''clear+ slip past them.
59
+ #
60
+ # == Wiring
61
+ #
62
+ # +Bash.new(passive_detector: PassiveCommandDetector.new, …)+ (or
63
+ # +.new(allow_git: true)+). {Bash} passes {#passive?} the raw command
64
+ # string; a non-command tool never consults this predicate, so there's no
65
+ # cross-tool coupling.
66
+ class PassiveCommandDetector
67
+ # Binaries passive in *every* argument form (no mutating mode
68
+ # reachable, with or without root). See +pikuri-code/DESIGN.md+
69
+ # (*Passive-command auto-approval*) for the curation principle and
70
+ # exclusions.
71
+ #
72
+ # +cd+, +true+, +false+ are shell builtins, not PATH binaries; the
73
+ # classifier only string-matches the first token, so they slot in. +cd+
74
+ # is meaningful only inside a chain (+cd notes && ls+); a bare +cd+ is
75
+ # harmlessly passive. +true+/+false+ serve the ubiquitous +cmd || true+
76
+ # idiom that swallows a probe's failure (+dpkg -S x || true+) — so the
77
+ # +true+ tail-segment must classify passive for the whole chain to.
78
+ PASSIVE_BINARIES = Set[
79
+ 'cd', 'true', 'false',
80
+ 'ls', 'cat', 'tac', 'nl', 'head', 'tail', 'wc',
81
+ 'grep', 'egrep', 'fgrep',
82
+ 'pwd', 'basename', 'dirname', 'realpath', 'readlink',
83
+ 'stat',
84
+ 'od', 'hexdump', 'strings',
85
+ 'cal',
86
+ 'uname', 'arch', 'nproc', 'lscpu', 'lsblk', 'lsusb', 'lspci',
87
+ 'df', 'du', 'free', 'vmstat', 'uptime', 'ps',
88
+ 'printenv', 'echo', 'printf',
89
+ 'whoami', 'id', 'groups',
90
+ 'which', 'type',
91
+ 'locale', 'getconf',
92
+ 'apropos', 'whatis',
93
+ 'dpkg-query'
94
+ ].freeze
95
+
96
+ # +systemctl+ subcommands with no mutating form — the only invocations
97
+ # {#passive_systemctl?} accepts, and the verb must appear immediately
98
+ # after +systemctl+ (so a leading +--root=/x+ is not passive).
99
+ # Excludes every state mutator (+start+/+stop+/+enable+/+mask+/
100
+ # +daemon-reload+ …). These only read; the pager (the one exec vector)
101
+ # spawns solely on a tty, which {Bash}'s piped stdout is not.
102
+ PASSIVE_SYSTEMCTL_SUBCOMMANDS = Set[
103
+ 'list-units', 'list-unit-files', 'list-timers', 'list-sockets',
104
+ 'list-jobs', 'list-dependencies', 'status', 'show', 'cat',
105
+ 'is-active', 'is-enabled', 'is-failed', 'get-default'
106
+ ].freeze
107
+
108
+ # +dpkg+ action flags with no mutating form — must appear immediately
109
+ # after +dpkg+ (so a leading +--admindir+/+--root+ redirection is not
110
+ # passive). +--get-selections+ is the read-side sibling of the excluded
111
+ # +--set-selections+. Excludes every database mutator (+-i+/+-r+/+-P+/
112
+ # +--unpack+/+--configure+/+--set-selections+ …).
113
+ PASSIVE_DPKG_ACTIONS = Set[
114
+ '-l', '--list', '-L', '--listfiles', '-s', '--status',
115
+ '-S', '--search', '-p', '--print-avail', '--get-selections'
116
+ ].freeze
117
+
118
+ # +apt+ subcommands that only query the cache — must appear immediately
119
+ # after +apt+ (so a leading +-o Foo=bar+ config injection is not
120
+ # passive). They run no pre/post-invoke hooks (those fire on the
121
+ # excluded mutating verbs +install+/+remove+/+update+/+upgrade+/…).
122
+ PASSIVE_APT_SUBCOMMANDS = Set[
123
+ 'search', 'show', 'list', 'policy', 'depends', 'rdepends'
124
+ ].freeze
125
+
126
+ # +apt-cache+ subcommands that only read the package cache — must appear
127
+ # immediately after +apt-cache+ (so a leading +-o Dir::Cache=/x+ / +-c
128
+ # file+ config injection is not passive). Excludes +gencaches+, which
129
+ # *builds* (writes) the cache — the one reason +apt-cache+ can't sit in
130
+ # {PASSIVE_BINARIES} wholesale like its pure-query sibling
131
+ # +dpkg-query+.
132
+ PASSIVE_APT_CACHE_SUBCOMMANDS = Set[
133
+ 'showpkg', 'showsrc', 'stats', 'dump', 'dumpavail', 'unmet',
134
+ 'search', 'show', 'depends', 'rdepends', 'pkgnames', 'policy',
135
+ 'madison', 'xvcg', 'dotty'
136
+ ].freeze
137
+
138
+ # +nvme+ (nvme-cli) subcommands with no mutating form — the only
139
+ # invocations {#passive_nvme?} accepts, and the verb must appear
140
+ # immediately after +nvme+ (so the +nvme <plugin> <verb>+ form, whose
141
+ # second word is a plugin name, is not passive). Excludes every mutator
142
+ # (+format+/+sanitize+/+set-feature+/+fw-download+/+fw-commit+/+reset+/
143
+ # the +write*+ family). Deliberately minimal — grow as real sessions
144
+ # surface further read verbs.
145
+ PASSIVE_NVME_SUBCOMMANDS = Set[
146
+ 'smart-log', 'list', 'id-ctrl', 'id-ns', 'error-log',
147
+ 'fw-log', 'get-log', 'get-feature'
148
+ ].freeze
149
+
150
+ # git verbs with no mutating form — the only commands {#passive_git?}
151
+ # accepts (and only under +allow_git:+). Excludes +tag+/+branch+/
152
+ # +remote+/+stash+/+reflog+ (each has a delete/create/expire form) and
153
+ # every working-tree / ref mutator (+checkout+ +reset+ +commit+ +merge+
154
+ # +pull+ +push+ +config+ +gc+ …).
155
+ PASSIVE_GIT_SUBCOMMANDS = Set[
156
+ 'status', 'diff', 'log', 'show', 'blame',
157
+ 'rev-parse', 'describe', 'shortlog'
158
+ ].freeze
159
+
160
+ # +dmesg+ short-option *letters* that mutate — buffer wipers (+-C+ clear,
161
+ # +-c+ read-clear) and console controls (+-D+ off, +-E+ on, +-n+
162
+ # set-level). {#passive_dmesg?} scans each cluster char-by-char, so a
163
+ # bundled +-xC+ trips on +C+.
164
+ DMESG_MUTATING_SHORT = Set['C', 'c', 'D', 'E', 'n'].freeze
165
+
166
+ # +dmesg+ long options that mutate — the long spellings of
167
+ # {DMESG_MUTATING_SHORT}. {#passive_dmesg?} strips a +=value+ tail
168
+ # before lookup (+--console-level=1+ matches +--console-level+).
169
+ DMESG_MUTATING_LONG = Set[
170
+ '--clear', '--read-clear', '--console-off', '--console-on',
171
+ '--console-level'
172
+ ].freeze
173
+
174
+ # +journalctl+ long options that mutate journal / FSS-key / catalog
175
+ # state — the space-maintenance ops (+--rotate+, +--flush+,
176
+ # +--relinquish-var+, +--smart-relinquish-space+, the +--vacuum-*+
177
+ # trimmers) and the on-disk writers (+--setup-keys+, +--update-catalog+).
178
+ # {#passive_journalctl?} strips a +=value+ tail before lookup. There's
179
+ # no short companion set — every journalctl maintenance op is long-only.
180
+ JOURNALCTL_MUTATING_LONG = Set[
181
+ '--rotate', '--flush', '--relinquish-var',
182
+ '--smart-relinquish-space',
183
+ '--vacuum-size', '--vacuum-time', '--vacuum-files',
184
+ '--setup-keys', '--update-catalog'
185
+ ].freeze
186
+
187
+ # +man+ short-option *letters* that spawn a program — +-H+ (render to
188
+ # HTML and launch +$BROWSER+: exec *and* egress) and +-P+ (run an
189
+ # arbitrary pager). {#passive_man?} scans each cluster char-by-char,
190
+ # so a bundled +-aH+ trips on +H+.
191
+ MAN_EXEC_SHORT = Set['H', 'P'].freeze
192
+
193
+ # +man+ long options that spawn a program — the long spellings of
194
+ # {MAN_EXEC_SHORT}. {#passive_man?} strips a +=value+ tail before
195
+ # lookup (+--pager=less+ matches +--pager+).
196
+ MAN_EXEC_LONG = Set['--html', '--pager'].freeze
197
+
198
+ # +rg+ (ripgrep) long options that run an arbitrary command — +--pre+
199
+ # (a preprocessor executed per searched file) and +--hostname-bin+ (run
200
+ # to resolve the hostname for hyperlinks). Both are pure exec vectors,
201
+ # which is why +rg+ can't sit beside +grep+ in {PASSIVE_BINARIES}.
202
+ # {#passive_rg?} strips a +=value+ tail before lookup
203
+ # (+--pre=/bin/sh+ matches +--pre+). rg has no *short* exec option, so
204
+ # a short cluster is passive data — no per-character scan needed
205
+ # (+-z+/+--search-zip+ shells only to fixed-name decompressors, already
206
+ # covered by the PATH-hijack residual, so it stays passive).
207
+ RG_EXEC_LONG = Set['--pre', '--hostname-bin'].freeze
208
+
209
+ # +file+ short-option *letters* that write — +-C+ compiles the magic
210
+ # file to a +.mgc+ on disk. {#passive_file?} scans each cluster
211
+ # char-by-char, so a bundled +-zC+ trips on +C+. This is +file+'s only
212
+ # mutating form; every other flag (+-i+, +-L+, +-s+, +-m magicfile+ …)
213
+ # reads.
214
+ FILE_WRITE_SHORT = Set['C'].freeze
215
+
216
+ # +file+ long options that write — the long spelling of
217
+ # {FILE_WRITE_SHORT}. {#passive_file?} strips a +=value+ tail before
218
+ # lookup.
219
+ FILE_WRITE_LONG = Set['--compile'].freeze
220
+
221
+ # +smartctl+ short-option *letters* that only read — SMART data (+-a+
222
+ # all, +-x+ xall, +-A+ attributes), device info (+-i+), health (+-H+),
223
+ # capabilities (+-c+), plus the device-type modifier +-d+ (its +TYPE+
224
+ # value is a separate token). {#passive_smartctl?} is passive iff every
225
+ # letter in a +-+-cluster is here — an allowlist, so a mutating letter
226
+ # (+-t+/+-s+/+-o+/+-S+/+-X+) or the value-sensitive +-l+ simply isn't
227
+ # listed and abstains (+-l selective,START-END+ *sets* a self-test span,
228
+ # which is why no +-l+ is classified).
229
+ #
230
+ # No +GIT_HARDENING+-style companion is needed, unlike +git+: audited,
231
+ # the allowlisted forms run no configured command (+smartd.conf+ is the
232
+ # *daemon's*; +-B+/+--drivedb+ loads a data file and isn't allowlisted).
233
+ SMARTCTL_READ_SHORT = Set['a', 'x', 'i', 'H', 'c', 'A', 'd'].freeze
234
+
235
+ # +smartctl+ long options that only read — the long spellings of
236
+ # {SMARTCTL_READ_SHORT} (+--device+ carries the +-d+ modifier).
237
+ # {#passive_smartctl?} strips a +=value+ tail before lookup
238
+ # (+--device=sat+ matches +--device+).
239
+ SMARTCTL_READ_LONG = Set[
240
+ '--all', '--xall', '--info', '--health',
241
+ '--capabilities', '--attributes', '--device'
242
+ ].freeze
243
+
244
+ # @param allow_git [Boolean] when +true+, additionally accept passive
245
+ # git invocations ({#passive_git?}). Default +false+. Sound only
246
+ # paired with {Pikuri::Code::Bash::GIT_HARDENING}, which closes the
247
+ # +core.fsmonitor+ vector; the caller accepts the narrow diff-driver
248
+ # residual (see +pikuri-code/DESIGN.md+).
249
+ def initialize(allow_git: false)
250
+ @allow_git = allow_git
251
+ end
252
+
253
+ # @param command [String, nil] the raw bash command string (as {Bash}
254
+ # received it — no +"$ "+ echo decoration).
255
+ # @return [Boolean] whether every {Tokenizer.tokenize} segment is a
256
+ # provably passive simple invocation. A +nil+ parse (more than a
257
+ # simple chain) or a +nil+/empty command is not passive.
258
+ def passive?(command)
259
+ return false if command.nil?
260
+
261
+ command = command.strip
262
+ return false if command.empty?
263
+
264
+ segments = Tokenizer.tokenize(command)
265
+ return segments.all? { |words| segment_passive?(words) } unless segments.nil?
266
+
267
+ # The strict parse rejected it. If its *only* complication is an
268
+ # unquoted glob, the glob fast path may still deem it passive.
269
+ glob_passive?(command)
270
+ end
271
+
272
+ private
273
+
274
+ # The pure-allowlist glob fast path: re-parse with globs kept literal
275
+ # ({Tokenizer.tokenize} +allow_glob:+) and pass iff every segment is
276
+ # a bare {PASSIVE_BINARIES} invocation.
277
+ #
278
+ # ls *.txt | wc -l # passive: every binary passive in any arg
279
+ # dmesg * # NOT passive: dmesg isn't pure-allowlist
280
+ #
281
+ # Sound because a glob is expanded by bash *after* this check, and a
282
+ # {PASSIVE_BINARIES} command has no mutating/exec/output-file form in
283
+ # *any* argument — so a glob that expands to a +-flag+-looking filename
284
+ # (or any filename) can't grow teeth. The recovered families
285
+ # (dpkg/apt/dmesg/journalctl/man/rg/file/nvme/smartctl/git) are
286
+ # deliberately *not*
287
+ # eligible: they classify by flag *spelling*, which a post-expansion
288
+ # glob would defeat (a planted +-C+/+--pre+ file smuggled past the
289
+ # denylist) — so any glob on them keeps delegating to the human. The
290
+ # +allow_glob+ re-parse still rejects every non-glob complication ($()
291
+ # substitution, a real redirect, an unbalanced quote …), so only a
292
+ # command whose sole sin was an unquoted glob reaches the check.
293
+ #
294
+ # @param command [String] the stripped command (strict parse already
295
+ # returned +nil+).
296
+ # @return [Boolean] whether every glob-tolerant segment is a bare
297
+ # pure-allowlist invocation.
298
+ def glob_passive?(command)
299
+ segments = Tokenizer.tokenize(command, allow_glob: true)
300
+ return false if segments.nil?
301
+
302
+ segments.all? { |words| glob_safe_binary?(words) }
303
+ end
304
+
305
+ # @param words [Array<String>] one glob-tolerant segment's words.
306
+ # @return [Boolean] whether it is a single bare {PASSIVE_BINARIES}
307
+ # invocation (or an empty comment segment). Unlike {#segment_passive?}
308
+ # this admits *only* the pure allowlist — no dpkg/dmesg/git recovery —
309
+ # because those can't be classified once a glob may expand into their
310
+ # flags. A first word with +/+ or one that is itself a glob is not in
311
+ # {PASSIVE_BINARIES} ⇒ not passive.
312
+ def glob_safe_binary?(words)
313
+ return true if words.empty?
314
+
315
+ first = words.first
316
+ !first.include?('/') && PASSIVE_BINARIES.include?(first)
317
+ end
318
+
319
+ # @param words [Array<String>] one segment's words from {Tokenizer}
320
+ # (+[]+ for a comment no-op).
321
+ # @return [Boolean] whether the segment is a single bare invocation of a
322
+ # {PASSIVE_BINARIES} entry (or a passive dpkg/apt/apt-cache/systemctl/
323
+ # file/dmesg/journalctl/man/rg/nvme/smartctl query, or under +allow_git:+
324
+ # a passive git invocation),
325
+ # or an empty (comment) segment. A first word containing +/+ is a path
326
+ # that could point at a planted binary ⇒ not passive.
327
+ def segment_passive?(words)
328
+ return true if words.empty?
329
+
330
+ first = words.first
331
+ return false if first.include?('/')
332
+ return true if PASSIVE_BINARIES.include?(first)
333
+ return true if passive_dpkg?(words)
334
+ return true if passive_apt?(words)
335
+ return true if passive_apt_cache?(words)
336
+ return true if passive_systemctl?(words)
337
+ return true if passive_file?(words)
338
+ return true if passive_dmesg?(words)
339
+ return true if passive_journalctl?(words)
340
+ return true if passive_man?(words)
341
+ return true if passive_rg?(words)
342
+ return true if passive_nvme?(words)
343
+ return true if passive_smartctl?(words)
344
+
345
+ @allow_git && passive_git?(words)
346
+ end
347
+
348
+ # @param words [Array<String>] one segment's words.
349
+ # @return [Boolean] whether it is +dpkg+ followed *immediately* by a
350
+ # {PASSIVE_DPKG_ACTIONS} flag (so a leading +--admindir+/+--root+
351
+ # redirection, and every install/remove form, are refused).
352
+ def passive_dpkg?(words)
353
+ return false unless words[0] == 'dpkg'
354
+
355
+ PASSIVE_DPKG_ACTIONS.include?(words[1])
356
+ end
357
+
358
+ # @param words [Array<String>] one segment's words.
359
+ # @return [Boolean] whether it is +apt+ followed *immediately* by a
360
+ # {PASSIVE_APT_SUBCOMMANDS} verb (so a leading +-o Foo=bar+ config
361
+ # injection, and every mutating verb, are refused).
362
+ def passive_apt?(words)
363
+ return false unless words[0] == 'apt'
364
+
365
+ PASSIVE_APT_SUBCOMMANDS.include?(words[1])
366
+ end
367
+
368
+ # @param words [Array<String>] one segment's words.
369
+ # @return [Boolean] whether it is +apt-cache+ followed *immediately* by a
370
+ # {PASSIVE_APT_CACHE_SUBCOMMANDS} verb (so a leading +-o Dir::Cache=/x+
371
+ # config injection, and the cache-building +gencaches+, are refused).
372
+ def passive_apt_cache?(words)
373
+ return false unless words[0] == 'apt-cache'
374
+
375
+ PASSIVE_APT_CACHE_SUBCOMMANDS.include?(words[1])
376
+ end
377
+
378
+ # @param words [Array<String>] one segment's words.
379
+ # @return [Boolean] whether it is +systemctl+ followed *immediately* by
380
+ # a {PASSIVE_SYSTEMCTL_SUBCOMMANDS} verb (so a leading global option
381
+ # +--root=/x+/+-H host+, and every state mutator, are refused).
382
+ def passive_systemctl?(words)
383
+ return false unless words[0] == 'systemctl'
384
+
385
+ PASSIVE_SYSTEMCTL_SUBCOMMANDS.include?(words[1])
386
+ end
387
+
388
+ # @param words [Array<String>] one segment's words.
389
+ # @return [Boolean] whether it is +dmesg+ with no mutating flag among
390
+ # its arguments ({DMESG_MUTATING_SHORT} / {DMESG_MUTATING_LONG}).
391
+ def passive_dmesg?(words)
392
+ return false unless words[0] == 'dmesg'
393
+
394
+ words.drop(1).none? { |t| dmesg_mutating_arg?(t) }
395
+ end
396
+
397
+ # @param token [String] one +dmesg+ argument.
398
+ # @return [Boolean] whether it names a mutating +dmesg+ flag — a long
399
+ # option in {DMESG_MUTATING_LONG} (ignoring any +=value+ tail) or a
400
+ # short cluster containing a {DMESG_MUTATING_SHORT} letter. A bare
401
+ # value token (no leading +-+) is passive data ⇒ +false+.
402
+ def dmesg_mutating_arg?(token)
403
+ if token.start_with?('--')
404
+ DMESG_MUTATING_LONG.include?(token.split('=', 2).first)
405
+ elsif token.start_with?('-')
406
+ token[1..].chars.any? { |c| DMESG_MUTATING_SHORT.include?(c) }
407
+ else
408
+ false
409
+ end
410
+ end
411
+
412
+ # @param words [Array<String>] one segment's words.
413
+ # @return [Boolean] whether it is +journalctl+ with no mutating flag
414
+ # among its arguments ({JOURNALCTL_MUTATING_LONG}). Same flag-denylist
415
+ # shape as {#passive_dmesg?}.
416
+ def passive_journalctl?(words)
417
+ return false unless words[0] == 'journalctl'
418
+
419
+ words.drop(1).none? { |t| journalctl_mutating_arg?(t) }
420
+ end
421
+
422
+ # @param token [String] one +journalctl+ argument.
423
+ # @return [Boolean] whether it names a {JOURNALCTL_MUTATING_LONG} flag
424
+ # (ignoring any +=value+ tail). A short cluster or bare value token
425
+ # never mutates ⇒ +false+.
426
+ def journalctl_mutating_arg?(token)
427
+ return false unless token.start_with?('--')
428
+
429
+ JOURNALCTL_MUTATING_LONG.include?(token.split('=', 2).first)
430
+ end
431
+
432
+ # @param words [Array<String>] one segment's words.
433
+ # @return [Boolean] whether it is +man+ with no program-spawning flag
434
+ # among its arguments ({MAN_EXEC_SHORT} / {MAN_EXEC_LONG}). Same
435
+ # flag-denylist shape as {#passive_dmesg?}, but the residual it
436
+ # guards is *exec/egress*, not mutation. The pure-read man-suite
437
+ # siblings +apropos+/+whatis+ have no such flag and sit directly in
438
+ # {PASSIVE_BINARIES}.
439
+ def passive_man?(words)
440
+ return false unless words[0] == 'man'
441
+
442
+ words.drop(1).none? { |t| man_exec_arg?(t) }
443
+ end
444
+
445
+ # @param token [String] one +man+ argument.
446
+ # @return [Boolean] whether it names a program-spawning +man+ flag — a
447
+ # long option in {MAN_EXEC_LONG} (ignoring any +=value+ tail) or a
448
+ # short cluster containing a {MAN_EXEC_SHORT} letter. A bare value
449
+ # token (no leading +-+) is passive data ⇒ +false+.
450
+ def man_exec_arg?(token)
451
+ if token.start_with?('--')
452
+ MAN_EXEC_LONG.include?(token.split('=', 2).first)
453
+ elsif token.start_with?('-')
454
+ token[1..].chars.any? { |c| MAN_EXEC_SHORT.include?(c) }
455
+ else
456
+ false
457
+ end
458
+ end
459
+
460
+ # @param words [Array<String>] one segment's words.
461
+ # @return [Boolean] whether it is +rg+ with no arbitrary-command flag
462
+ # among its arguments ({RG_EXEC_LONG}). Same flag-denylist shape as
463
+ # {#passive_man?}, but rg's exec flags are long-only so there's no
464
+ # short-cluster scan. Residual: rg also reads +--pre+ from a config
465
+ # file named by +$RIPGREP_CONFIG_PATH+, which this can't see — but
466
+ # setting that env already presupposes control of the agent's
467
+ # environment, the same class as the PATH-hijack residual.
468
+ def passive_rg?(words)
469
+ return false unless words[0] == 'rg'
470
+
471
+ words.drop(1).none? { |t| rg_exec_arg?(t) }
472
+ end
473
+
474
+ # @param token [String] one +rg+ argument.
475
+ # @return [Boolean] whether it names a {RG_EXEC_LONG} flag (ignoring any
476
+ # +=value+ tail). A short cluster or bare value token never execs ⇒
477
+ # +false+.
478
+ def rg_exec_arg?(token)
479
+ return false unless token.start_with?('--')
480
+
481
+ RG_EXEC_LONG.include?(token.split('=', 2).first)
482
+ end
483
+
484
+ # @param words [Array<String>] one segment's words.
485
+ # @return [Boolean] whether it is +file+ with no writing flag among its
486
+ # arguments ({FILE_WRITE_SHORT} / {FILE_WRITE_LONG}). Same flag-denylist
487
+ # shape as {#passive_man?}.
488
+ def passive_file?(words)
489
+ return false unless words[0] == 'file'
490
+
491
+ words.drop(1).none? { |t| file_write_arg?(t) }
492
+ end
493
+
494
+ # @param token [String] one +file+ argument.
495
+ # @return [Boolean] whether it names a writing +file+ flag — a long
496
+ # option in {FILE_WRITE_LONG} (ignoring any +=value+ tail) or a short
497
+ # cluster containing a {FILE_WRITE_SHORT} letter. A bare value token
498
+ # (no leading +-+) is passive data ⇒ +false+.
499
+ def file_write_arg?(token)
500
+ if token.start_with?('--')
501
+ FILE_WRITE_LONG.include?(token.split('=', 2).first)
502
+ elsif token.start_with?('-')
503
+ token[1..].chars.any? { |c| FILE_WRITE_SHORT.include?(c) }
504
+ else
505
+ false
506
+ end
507
+ end
508
+
509
+ # @param words [Array<String>] one segment's words.
510
+ # @return [Boolean] whether it is +nvme+ followed *immediately* by a
511
+ # {PASSIVE_NVME_SUBCOMMANDS} verb (so the +nvme <plugin> <verb>+ form
512
+ # and every mutating verb are refused).
513
+ def passive_nvme?(words)
514
+ return false unless words[0] == 'nvme'
515
+
516
+ PASSIVE_NVME_SUBCOMMANDS.include?(words[1])
517
+ end
518
+
519
+ # @param words [Array<String>] one segment's words.
520
+ # @return [Boolean] whether it is +smartctl+ with *every* +-+-leading
521
+ # argument a known read flag ({SMARTCTL_READ_SHORT} /
522
+ # {SMARTCTL_READ_LONG}). A flag *allowlist* (inverse polarity of the
523
+ # dmesg/man/file denylists): an unrecognized flag ⇒ not passive, so
524
+ # the mutating and value-sensitive forms abstain with no enumeration.
525
+ # A non-flag token is a device path or a flag value (+-d sat+) and is
526
+ # always safe — +smartctl+ mutates only via a flag.
527
+ def passive_smartctl?(words)
528
+ return false unless words[0] == 'smartctl'
529
+
530
+ words.drop(1).all? { |t| smartctl_read_arg?(t) }
531
+ end
532
+
533
+ # @param token [String] one +smartctl+ argument.
534
+ # @return [Boolean] whether it is a read-only argument — a long option in
535
+ # {SMARTCTL_READ_LONG} (ignoring any +=value+ tail), a short cluster
536
+ # whose every letter is in {SMARTCTL_READ_SHORT}, or a bare non-flag
537
+ # token (device path / flag value). A bundled cluster mixing in an
538
+ # unlisted letter (+-at+) ⇒ +false+ ⇒ abstain — the safe direction.
539
+ def smartctl_read_arg?(token)
540
+ if token.start_with?('--')
541
+ SMARTCTL_READ_LONG.include?(token.split('=', 2).first)
542
+ elsif token.start_with?('-')
543
+ token[1..].chars.all? { |c| SMARTCTL_READ_SHORT.include?(c) }
544
+ else
545
+ true
546
+ end
547
+ end
548
+
549
+ # @param words [Array<String>] one segment's words.
550
+ # @return [Boolean] whether it is +git+ followed *immediately* by a
551
+ # {PASSIVE_GIT_SUBCOMMANDS} verb with no +--output+ argument.
552
+ # Requiring the verb right after +git+ refuses a leading global option
553
+ # (+-c core.pager=evil+, +-C /other+, +--git-dir+, +--exec-path+) —
554
+ # the inline-config and repo-redirection escapes — since the second
555
+ # word then starts with +-+. +--output+ is the one in-tool file writer
556
+ # in this verb set (+git diff --output=f+). +--cached+/+--staged+/
557
+ # +--stat+/pathspecs pass free.
558
+ def passive_git?(words)
559
+ return false unless words[0] == 'git'
560
+ return false unless PASSIVE_GIT_SUBCOMMANDS.include?(words[1])
561
+
562
+ words.drop(2).none? { |t| t.start_with?('--output') }
563
+ end
564
+ end
565
+ end
566
+ end
567
+ end