pikuri-code 0.0.6 → 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 +387 -354
- data/lib/pikuri/code/bash/tokenizer.rb +310 -0
- data/lib/pikuri/code/bash.rb +206 -117
- 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 +42 -98
- data/lib/pikuri-code.rb +4 -12
- data/prompts/coding-system-prompt.txt +18 -16
- data/prompts/persona-git-repo-researcher.txt +2 -1
- metadata +17 -14
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Code
|
|
7
|
+
class Bash
|
|
8
|
+
# A deliberately narrow shell tokenizer: turns a command string into one
|
|
9
|
+
# word list per *sequencer segment*, or +nil+ when it can't prove the
|
|
10
|
+
# command is such a simple chain. It makes **no passivity judgement** —
|
|
11
|
+
# that policy lives in {PassiveCommandDetector}, which classifies these
|
|
12
|
+
# word lists. Lexing (here) is split from classification (the passive detector) so
|
|
13
|
+
# a second consumer could reuse the parse.
|
|
14
|
+
#
|
|
15
|
+
# {.tokenize} returns +Array<Array<String>>+ — segments (cut on
|
|
16
|
+
# {SEQUENCERS}) of de-quoted, de-redirected words, an empty list marking a
|
|
17
|
+
# pure-comment no-op — or +nil+ meaning "more than a simple chain" (a
|
|
18
|
+
# metacharacter, unsafe redirect, unbalanced quote, too many segments).
|
|
19
|
+
# Conservative by contract: when in doubt it returns +nil+ so the caller
|
|
20
|
+
# delegates to a human; it never silently drops a dangerous byte.
|
|
21
|
+
#
|
|
22
|
+
# +allow_glob:+ is the one knob: off (default) an unquoted glob/brace/
|
|
23
|
+
# tilde ({GLOB_CHARS}) rejects like any metacharacter; on, it is kept as
|
|
24
|
+
# a literal word byte and the *authorization* layer decides. This is
|
|
25
|
+
# sound because those chars only ever expand to *words*, never to an
|
|
26
|
+
# operator or a new command — so keeping them literal can't smuggle a
|
|
27
|
+
# +;+/+|+/+$()+ past the lexer. The detector flips it on only for the
|
|
28
|
+
# pure-allowlist glob fast path; see {PassiveCommandDetector}.
|
|
29
|
+
#
|
|
30
|
+
# == Why splitting on the sequencers is faithful
|
|
31
|
+
#
|
|
32
|
+
# {.split_segments} cuts only on a genuine control operator, never one
|
|
33
|
+
# hidden inside a string: it tracks quote state (single *and* double) and
|
|
34
|
+
# won't split inside a quoted span, and the backslash stays forbidden, so
|
|
35
|
+
# nothing escapes a +;+/+|+/newline past it. Every *other* metacharacter
|
|
36
|
+
# stays forbidden per segment, so an arbitrary-target redirect (+ps | grep
|
|
37
|
+
# x > out+), a background +&+ (+ls & pwd+) or +|&+ trip the metacharacter
|
|
38
|
+
# check ⇒ +nil+. The lone exception is a write-nothing redirect fragment
|
|
39
|
+
# ({SAFE_REDIRECT}: +2>&1+, +2>/dev/null+ …), consumed and dropped — fd
|
|
40
|
+
# duplication and +/dev/null+ discards add no capability.
|
|
41
|
+
#
|
|
42
|
+
# == Quote removal
|
|
43
|
+
#
|
|
44
|
+
# {.scan_segment} performs genuine shell quote-removal: the marks are
|
|
45
|
+
# dropped and the interior kept *verbatim* and *literal*, so a quoted +|+
|
|
46
|
+
# neither splits nor trips the gate (+echo 'a | b'+ is one echo). Single
|
|
47
|
+
# quotes decode *unconditionally* — the POSIX interior is purely literal,
|
|
48
|
+
# closed by the next +'+. Double quotes decode *only when verifiably
|
|
49
|
+
# inert*: POSIX keeps three characters live inside +"..."+ — +$+
|
|
50
|
+
# (expansion), the backtick (command substitution), +\+ (escaping) — so a
|
|
51
|
+
# span containing any {DQUOTE_LIVE} byte returns +nil+ (delegate, never
|
|
52
|
+
# guess), and one containing none is decoded like a single-quoted span.
|
|
53
|
+
# This admits the common LLM shape +grep "foo bar" f+ while keeping the
|
|
54
|
+
# failure mode "asks the human too often". An unbalanced quote → +nil+.
|
|
55
|
+
#
|
|
56
|
+
# Genuine quote-removal (not a placeholder rewrite) is what makes the
|
|
57
|
+
# spelling-based classifiers in {PassiveCommandDetector} sound — the
|
|
58
|
+
# cross-cutting "why the de-quoted word must be the *true* word, and why an
|
|
59
|
+
# allowlist tolerates a shortcut a denylist doesn't" argument lives in
|
|
60
|
+
# +pikuri-code/DESIGN.md+ (*The soundness hinge: true de-quoted words*).
|
|
61
|
+
#
|
|
62
|
+
# The classic mis-parse — +echo "a\"b; rm x"+, where a naive lexer closes
|
|
63
|
+
# the span at the escaped quote and exposes the +;+ — cannot happen: the
|
|
64
|
+
# scan bails on the +\+ *before* reaching the quote it escapes.
|
|
65
|
+
# ({.split_segments}'s simpler tracker does close a span at an escaped
|
|
66
|
+
# quote, but any command where that matters carries a +\+ outside single
|
|
67
|
+
# quotes into *some* segment, which {.scan_segment} rejects — so the
|
|
68
|
+
# divergence can only produce +nil+, never a mis-parsed approval.)
|
|
69
|
+
#
|
|
70
|
+
# Bash's fourth live character, +!+ (history expansion), is NOT gated
|
|
71
|
+
# inside the span: it fires only in interactive shells, and {Bash} always
|
|
72
|
+
# spawns +bash -c+ (histexpand off), so +echo "done!"+ is literal in every
|
|
73
|
+
# shell this reaches. Outside quotes +!+ stays forbidden.
|
|
74
|
+
module Tokenizer
|
|
75
|
+
# Any shell metacharacter or ASCII control byte that, *outside* a quoted
|
|
76
|
+
# span, means the segment is more than a single simple invocation ⇒
|
|
77
|
+
# +nil+. {.scan_segment} consults this per character only after handling
|
|
78
|
+
# quotes, whitespace, the {SAFE_REDIRECT} forms and {GLOB_CHARS}, so a
|
|
79
|
+
# safe redirect's +>+/+&+ and every quoted-argument byte never reach it.
|
|
80
|
+
#
|
|
81
|
+
# The backslash +\+ stays here (still forbidden): decoding it would
|
|
82
|
+
# reopen the parsing surface the quote handling avoids. The double quote
|
|
83
|
+
# is *not* listed — like +'+ it is handled by {.scan_segment}'s quote
|
|
84
|
+
# branches first. The glob/brace/tilde chars are *not* here either —
|
|
85
|
+
# they live in {GLOB_CHARS}, which {.scan_segment} rejects by default
|
|
86
|
+
# but keeps as literal word bytes under +allow_glob:+. Note +=+ is *not*
|
|
87
|
+
# here (+--color=never+); a leading +VAR=val+ is caught because the
|
|
88
|
+
# first word then isn't a bare allowed binary.
|
|
89
|
+
SHELL_METACHARACTERS = /[;&|<>$`()\\!#\x00-\x1f]/
|
|
90
|
+
|
|
91
|
+
# The shell *expansion* characters — pathname globbing (+*+ +?+
|
|
92
|
+
# +[...]+), brace expansion (+{a,b}+), tilde expansion (+~+). Split out
|
|
93
|
+
# of {SHELL_METACHARACTERS} because they are the one metacharacter class
|
|
94
|
+
# that only ever expands to *words* (filenames/paths), never to an
|
|
95
|
+
# operator or a new command (bash tokenizes operators *before*
|
|
96
|
+
# expansion and never re-scans the result). {.scan_segment} rejects
|
|
97
|
+
# them by default (⇒ +nil+, as before), but under +allow_glob: true+
|
|
98
|
+
# keeps them as literal word bytes so the caller can classify them where
|
|
99
|
+
# sound — see {.tokenize}'s +allow_glob:+ and the
|
|
100
|
+
# {PassiveCommandDetector} glob fast path.
|
|
101
|
+
GLOB_CHARS = /[*?\[\]{}~]/
|
|
102
|
+
|
|
103
|
+
# The three characters POSIX keeps live inside +"..."+ — +$+
|
|
104
|
+
# (expansion), the backtick (command substitution), +\+ (escaping,
|
|
105
|
+
# including of the closing +"+). {.scan_segment} returns +nil+ when any
|
|
106
|
+
# appears inside a double-quoted span; every other interior byte is
|
|
107
|
+
# literal. See the class header on why +!+ is not gated.
|
|
108
|
+
DQUOTE_LIVE = /[$`\\]/
|
|
109
|
+
|
|
110
|
+
# Shell control operators that merely *sequence or pipe* commands.
|
|
111
|
+
# {.split_segments} cuts on these outside quotes only (a quoted +|+/+;+
|
|
112
|
+
# is literal data). Alternation order matters: two-char +&&+/+||+ before
|
|
113
|
+
# single +|+ so +a || b+ splits into two segments, not three. A newline
|
|
114
|
+
# is a command terminator like +;+; a literal newline would sit inside a
|
|
115
|
+
# quoted span, where it is treated as data.
|
|
116
|
+
SEQUENCERS = /&&|\|\||;|\||\n/
|
|
117
|
+
|
|
118
|
+
# Defensive ceiling on sequencer-joined segments — a sanity backstop
|
|
119
|
+
# against pathological input, set high enough to clear a real generated
|
|
120
|
+
# batch (a +dpkg -S … || true+ probe per unit file across dozens of
|
|
121
|
+
# files). Beyond it the whole command returns +nil+.
|
|
122
|
+
MAX_CHAIN = 64
|
|
123
|
+
|
|
124
|
+
# A redirect fragment that writes nothing. {.scan_segment} anchors this
|
|
125
|
+
# at a +>+ (or +&>+) and, on a match, consumes and drops it (so the
|
|
126
|
+
# +>+/+&+ never reaches the {SHELL_METACHARACTERS} gate); a
|
|
127
|
+
# non-matching +>+ is a real redirect ⇒ +nil+. Two safe families:
|
|
128
|
+
#
|
|
129
|
+
# * fd *duplication* — +[n]>&[m]+ (+2>&1+ merges stderr into stdout):
|
|
130
|
+
# pure in-process fd-table manipulation, never a file.
|
|
131
|
+
# * *discard* to +/dev/null+ — +[n|&]>[>] /dev/null+ (append forms too).
|
|
132
|
+
#
|
|
133
|
+
# The target is pinned to *exactly* +/dev/null+ by the +(?=\s|\z)+
|
|
134
|
+
# lookahead, so a look-alike (+2>/dev/nullx+) does not match ⇒ +nil+, as
|
|
135
|
+
# does any arbitrary-file redirect. +\s*+ accepts glued or spaced. The
|
|
136
|
+
# leading +[0-9]*+ is the fd {.scan_segment} accumulated into the
|
|
137
|
+
# current word (the +2+ of +2>&1+); it trims those digits on a match.
|
|
138
|
+
SAFE_REDIRECT = %r{[0-9]*>&[0-9]+|(?:[0-9]|&)?>>?\s*/dev/null(?=\s|\z)}
|
|
139
|
+
|
|
140
|
+
class << self
|
|
141
|
+
# Parse the command into one word list per {SEQUENCERS} segment, or
|
|
142
|
+
# +nil+ if it can't be safely analyzed as such a chain.
|
|
143
|
+
#
|
|
144
|
+
# @param command [String] the command (a +bin/pikuri-*+ caller should
|
|
145
|
+
# strip any +"$ "+ echo prefix first).
|
|
146
|
+
# @param allow_glob [Boolean] when +true+, an unquoted {GLOB_CHARS}
|
|
147
|
+
# byte is kept as a literal word byte instead of rejecting the
|
|
148
|
+
# segment. Default +false+ (a glob ⇒ +nil+, the strict behavior).
|
|
149
|
+
# A quoted glob char is literal either way — this flag only affects
|
|
150
|
+
# *unquoted* ones. The caller opts in only where a post-expansion
|
|
151
|
+
# glob is provably harmless (see the {PassiveCommandDetector} glob
|
|
152
|
+
# fast path).
|
|
153
|
+
# @return [Array<Array<String>>, nil] one de-redirected, de-quoted
|
|
154
|
+
# word list per segment (empty list = pure-comment no-op), or +nil+
|
|
155
|
+
# — more than {MAX_CHAIN} segments, an unbalanced quote, a
|
|
156
|
+
# {DQUOTE_LIVE} byte inside double quotes, an unquoted {GLOB_CHARS}
|
|
157
|
+
# byte (unless +allow_glob:+), or any segment empty (a dangling
|
|
158
|
+
# sequencer) or not a simple invocation.
|
|
159
|
+
def tokenize(command, allow_glob: false)
|
|
160
|
+
segments = split_segments(command)
|
|
161
|
+
return nil if segments.nil?
|
|
162
|
+
|
|
163
|
+
words_per_segment = segments.map { |s| scan_segment(s, allow_glob) }
|
|
164
|
+
return nil if words_per_segment.any?(&:nil?)
|
|
165
|
+
|
|
166
|
+
words_per_segment
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# Split the command into raw segment strings on {SEQUENCERS}, but only
|
|
172
|
+
# *outside* quotes (a quoted operator stays literal data). Quote marks
|
|
173
|
+
# are preserved here and removed per-segment by {.scan_segment}. This
|
|
174
|
+
# tracker is escape-blind (closes a span at a +\"+ bash would treat as
|
|
175
|
+
# escaped), safe because such a command carries a +\+ into some
|
|
176
|
+
# segment that {.scan_segment} rejects — see the class header.
|
|
177
|
+
#
|
|
178
|
+
# @param command [String] the command.
|
|
179
|
+
# @return [Array<String>, nil] the raw segments (quotes intact; empty
|
|
180
|
+
# strings kept, so a dangling operator yields an empty segment
|
|
181
|
+
# {.scan_segment} rejects), or +nil+ on an unbalanced quote or more
|
|
182
|
+
# than {MAX_CHAIN} segments.
|
|
183
|
+
def split_segments(command)
|
|
184
|
+
segments = []
|
|
185
|
+
buf = +''
|
|
186
|
+
quote = nil # the quote character we are inside, or nil
|
|
187
|
+
i = 0
|
|
188
|
+
while i < command.length
|
|
189
|
+
char = command[i]
|
|
190
|
+
if quote
|
|
191
|
+
buf << char
|
|
192
|
+
quote = nil if char == quote
|
|
193
|
+
i += 1
|
|
194
|
+
elsif char == "'" || char == '"'
|
|
195
|
+
buf << char
|
|
196
|
+
quote = char
|
|
197
|
+
i += 1
|
|
198
|
+
elsif (op = SEQUENCERS.match(command, i)) && op.begin(0) == i
|
|
199
|
+
segments << buf
|
|
200
|
+
buf = +''
|
|
201
|
+
i += op[0].length
|
|
202
|
+
else
|
|
203
|
+
buf << char
|
|
204
|
+
i += 1
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
return nil if quote
|
|
208
|
+
|
|
209
|
+
segments << buf
|
|
210
|
+
segments.size > MAX_CHAIN ? nil : segments
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Reduce one sequencer segment to its de-quoted word list, or +nil+ to
|
|
214
|
+
# reject the whole command. A left-to-right scan doing shell
|
|
215
|
+
# quote-removal (the word handed back is the true argument, +--clear+
|
|
216
|
+
# not +--_clear+ — see the class header). Outside quotes: whitespace
|
|
217
|
+
# ends a word, a {SAFE_REDIRECT} fragment is consumed and dropped, a
|
|
218
|
+
# {SHELL_METACHARACTERS} byte rejects, and a {GLOB_CHARS} byte rejects
|
|
219
|
+
# unless +allow_glob+ (then it is kept literal, like any other byte).
|
|
220
|
+
#
|
|
221
|
+
# @param segment [String] one {SEQUENCERS} segment, not yet stripped.
|
|
222
|
+
# @param allow_glob [Boolean] keep an unquoted {GLOB_CHARS} byte as a
|
|
223
|
+
# literal word byte instead of rejecting (see {.tokenize}).
|
|
224
|
+
# @return [Array<String>, nil] the segment's de-quoted words with any
|
|
225
|
+
# {SAFE_REDIRECT} fragments removed; +[]+ for a pure-comment
|
|
226
|
+
# segment; or +nil+ — an empty segment, an unbalanced quote, a
|
|
227
|
+
# {DQUOTE_LIVE} byte inside double quotes, a {SHELL_METACHARACTERS}
|
|
228
|
+
# byte, an unquoted {GLOB_CHARS} byte (unless +allow_glob+), or a
|
|
229
|
+
# redirect that is not write-nothing.
|
|
230
|
+
def scan_segment(segment, allow_glob = false)
|
|
231
|
+
command = segment.strip
|
|
232
|
+
return nil if command.empty?
|
|
233
|
+
|
|
234
|
+
# A segment starting with +#+ is a shell comment — a no-op. Sound
|
|
235
|
+
# because every segment boundary is a command separator, where +#+
|
|
236
|
+
# genuinely opens a comment; a mid-segment +#+ (+ls #+) still trips
|
|
237
|
+
# the gate below.
|
|
238
|
+
return [] if command.start_with?('#')
|
|
239
|
+
|
|
240
|
+
words = []
|
|
241
|
+
word = nil # current word buffer; nil between words
|
|
242
|
+
quote = nil # the quote character we are inside, or nil
|
|
243
|
+
i = 0
|
|
244
|
+
while i < command.length
|
|
245
|
+
char = command[i]
|
|
246
|
+
if quote == "'"
|
|
247
|
+
# Single-quote interior: literal, never a separator/metachar.
|
|
248
|
+
char == "'" ? (quote = nil) : (word = (word || +'') << char)
|
|
249
|
+
i += 1
|
|
250
|
+
elsif quote == '"'
|
|
251
|
+
# Double-quote interior: literal too — unless one of the
|
|
252
|
+
# POSIX-live bytes appears, which rejects the segment.
|
|
253
|
+
if char == '"'
|
|
254
|
+
quote = nil
|
|
255
|
+
elsif DQUOTE_LIVE.match?(char)
|
|
256
|
+
return nil
|
|
257
|
+
else
|
|
258
|
+
word = (word || +'') << char
|
|
259
|
+
end
|
|
260
|
+
i += 1
|
|
261
|
+
elsif char == "'" || char == '"'
|
|
262
|
+
word ||= +'' # an opening quote starts a (maybe empty) word
|
|
263
|
+
quote = char
|
|
264
|
+
i += 1
|
|
265
|
+
elsif char == ' ' || char == "\t"
|
|
266
|
+
words << word unless word.nil?
|
|
267
|
+
word = nil
|
|
268
|
+
i += 1
|
|
269
|
+
elsif char == '>' || (char == '&' && command[i + 1] == '>')
|
|
270
|
+
# A redirect. Anchor {SAFE_REDIRECT} at the fd prefix (the
|
|
271
|
+
# trailing digits of the current word, e.g. the +2+ of
|
|
272
|
+
# +2>&1+), accepting only the write-nothing forms; anything
|
|
273
|
+
# else (a real target) returns +nil+. The fd digits belonged
|
|
274
|
+
# to the redirect, not the word, so trim them off.
|
|
275
|
+
digits = char == '>' && word ? word[/\d*\z/].length : 0
|
|
276
|
+
redirect = SAFE_REDIRECT.match(command, i - digits)
|
|
277
|
+
return nil unless redirect && redirect.begin(0) == i - digits
|
|
278
|
+
|
|
279
|
+
if digits.positive?
|
|
280
|
+
word = word[0...-digits]
|
|
281
|
+
word = nil if word.empty?
|
|
282
|
+
end
|
|
283
|
+
words << word unless word.nil?
|
|
284
|
+
word = nil
|
|
285
|
+
i = redirect.end(0)
|
|
286
|
+
elsif GLOB_CHARS.match?(char)
|
|
287
|
+
# An unquoted glob/brace/tilde byte. bash would *expand* it;
|
|
288
|
+
# reject unless the caller opted into keeping it literal (then
|
|
289
|
+
# it is treated exactly like any ordinary word byte below).
|
|
290
|
+
return nil unless allow_glob
|
|
291
|
+
|
|
292
|
+
word = (word || +'') << char
|
|
293
|
+
i += 1
|
|
294
|
+
elsif SHELL_METACHARACTERS.match?(char)
|
|
295
|
+
return nil
|
|
296
|
+
else
|
|
297
|
+
word = (word || +'') << char
|
|
298
|
+
i += 1
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
return nil if quote
|
|
302
|
+
|
|
303
|
+
words << word unless word.nil?
|
|
304
|
+
words.empty? ? nil : words
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|