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 +4 -4
- data/README.md +9 -8
- data/lib/pikuri/code/bash/passive_command_detector.rb +567 -0
- data/lib/pikuri/code/bash/sandbox.rb +367 -353
- data/lib/pikuri/code/bash/tokenizer.rb +310 -0
- data/lib/pikuri/code/bash.rb +181 -178
- data/lib/pikuri/code/enter_plan_mode.rb +55 -0
- data/lib/pikuri/code/exit_plan_mode.rb +85 -0
- data/lib/pikuri/code/extension.rb +164 -0
- data/lib/pikuri/code/git_clone.rb +57 -74
- data/lib/pikuri/code/git_repo_researcher.rb +18 -48
- data/lib/pikuri/code/plan_mode_changed.rb +24 -0
- data/lib/pikuri/code/toolchain_paths.rb +40 -93
- data/lib/pikuri-code.rb +4 -12
- data/prompts/coding-system-prompt.txt +16 -14
- data/prompts/persona-git-repo-researcher.txt +2 -1
- metadata +15 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47745902c3eac2e15a4e28b09b6b185b093b836bc4e39e53899435be9b2f5e71
|
|
4
|
+
data.tar.gz: 7272ad476418e324f4b86ef99826b47effbe1dda03f58a0db1a98223c5929a99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
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 [`
|
|
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
|
|
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
|
|
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
|