pikuri-workspace 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 +8 -4
- data/lib/pikuri/workspace/confirmer/terminal.rb +245 -0
- data/lib/pikuri/workspace/confirmer.rb +139 -122
- data/lib/pikuri/workspace/edit.rb +167 -91
- data/lib/pikuri/workspace/extension.rb +188 -0
- data/lib/pikuri/workspace/file_list.rb +122 -0
- data/lib/pikuri/workspace/filesystem.rb +303 -331
- data/lib/pikuri/workspace/listing.rb +194 -0
- data/lib/pikuri/workspace/project_root.rb +34 -59
- data/lib/pikuri/workspace/read.rb +131 -160
- data/lib/pikuri/workspace/read_only.rb +134 -0
- data/lib/pikuri/workspace/search/glob.rb +324 -0
- data/lib/pikuri/workspace/search/grep.rb +364 -0
- data/lib/pikuri/workspace/search/utils.rb +173 -0
- data/lib/pikuri/workspace/workspace.rb +102 -0
- data/lib/pikuri/workspace/write.rb +108 -66
- data/lib/pikuri/workspace/write_gate.rb +72 -0
- data/lib/pikuri-workspace.rb +4 -9
- metadata +27 -5
- data/lib/pikuri/workspace/glob.rb +0 -322
- data/lib/pikuri/workspace/grep.rb +0 -350
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 120a595848652290fe60f2505a6cf74ff7d8f83764da5a22b89c7f71c95b5796
|
|
4
|
+
data.tar.gz: cb6348c77246df64f29b03cdb6d846abbd4bfa97f9c4830c155fd978ea1b65cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3d79a08b112424947f74667a24750e82a86539b61a1edfdbabb2ab2270d610d0d91442c05aab6f186f869dff70376259fecc6172ca60c23d43560797e536de2
|
|
7
|
+
data.tar.gz: 1fe906ff062d7dc37e1666f8f2ebf56c18eb3e7bc389fcef5bf8f6334548f1cc10684113f2d1331af11b92befd33ba72f5dac217d42ef02cc668b6f66262f3c6
|
data/README.md
CHANGED
|
@@ -8,8 +8,11 @@ Self-contained "operate on a directory tree" toolkit:
|
|
|
8
8
|
project root + explicit readable / writable prefix lists, with an
|
|
9
9
|
optional ephemeral temp playground. Rejects `..`-escapes and
|
|
10
10
|
symlinks that resolve outside the configured roots.
|
|
11
|
-
- `Pikuri::Workspace::Confirmer` — abstract base
|
|
12
|
-
`
|
|
11
|
+
- `Pikuri::Workspace::Confirmer` — abstract base for approving
|
|
12
|
+
user-state mutations, plus `AUTO_APPROVE` (headless) and `Terminal`
|
|
13
|
+
(stdin/stdout). `Terminal` is written for pikuri's own single-threaded
|
|
14
|
+
demo scripts — a real app implements this seam itself and reads
|
|
15
|
+
`Terminal` as the worked example.
|
|
13
16
|
- Five file tools: `Pikuri::Workspace::Read`,
|
|
14
17
|
`Pikuri::Workspace::Write`, `Pikuri::Workspace::Edit`,
|
|
15
18
|
`Pikuri::Workspace::Grep`, `Pikuri::Workspace::Glob`.
|
|
@@ -31,7 +34,8 @@ require 'pikuri-core'
|
|
|
31
34
|
require 'pikuri-workspace'
|
|
32
35
|
|
|
33
36
|
workspace = Pikuri::Workspace::Filesystem.new(project_root: Dir.pwd)
|
|
34
|
-
|
|
37
|
+
# Fine for a script like this one; write your own for a real app.
|
|
38
|
+
confirmer = Pikuri::Workspace::Confirmer::Terminal.new
|
|
35
39
|
|
|
36
40
|
agent = Pikuri::Agent.new(
|
|
37
41
|
transport: ...,
|
|
@@ -57,7 +61,7 @@ writable playground via `Dir.mktmpdir` — its path is exposed as
|
|
|
57
61
|
|
|
58
62
|
- **Narrative walkthrough:** the "Workspace seam" and "Confirmer
|
|
59
63
|
seam" sections of
|
|
60
|
-
[chapter
|
|
64
|
+
[the coding-agent chapter of the pikuri guide](../book/code.md) — what
|
|
61
65
|
each kwarg of `Filesystem.new` controls, when to use the
|
|
62
66
|
`AllowAll` variant, and how the seams fit alongside `Sandbox`.
|
|
63
67
|
- **API reference:** browse the YARD docs at
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rainbow'
|
|
4
|
+
require 'reline'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module Pikuri
|
|
8
|
+
module Workspace
|
|
9
|
+
class Confirmer
|
|
10
|
+
# Stdin/stdout implementation, written for pikuri's own single-threaded
|
|
11
|
+
# +bin/+ scripts — **not a component to build a host on**. A real host
|
|
12
|
+
# (TUI, web) owns its screen and its event loop, so it cannot have a
|
|
13
|
+
# library class calling +puts+ and +gets+ behind its back; it writes its
|
|
14
|
+
# own {Confirmer} and this is the worked example to read while doing it.
|
|
15
|
+
# The one piece to carry over is not the chrome but the warning-gated
|
|
16
|
+
# retype below — everything else is a terminal's answer to a problem your
|
|
17
|
+
# UI will answer differently.
|
|
18
|
+
#
|
|
19
|
+
# Renders the request as up to several lines
|
|
20
|
+
# (a leading +puts+ separates it from any streamed output above):
|
|
21
|
+
#
|
|
22
|
+
# 1. a bold-yellow warning block (one line per {Pikuri::Sanitizer::Warning})
|
|
23
|
+
# when the sanitizer flagged the question or detail
|
|
24
|
+
# 2. the question, bold
|
|
25
|
+
# 3. the detail, dim — omitted when +nil+
|
|
26
|
+
# 4. the answer cue
|
|
27
|
+
#
|
|
28
|
+
# Question and detail pass through {Pikuri::Sanitizer}, which neutralizes
|
|
29
|
+
# control bytes (else a model could craft +"\rrm -rf ~/"+ that overwrites
|
|
30
|
+
# the echoed line after the user read it) and reports *why* it was unsafe.
|
|
31
|
+
# Colors are Rainbow (self-disables on non-TTY).
|
|
32
|
+
#
|
|
33
|
+
# The human *chrome* — the one confirmer that renders a dialog and reads an
|
|
34
|
+
# answer. In its own file so the base seam carries no rendering deps
|
|
35
|
+
# (Rainbow / Reline / tmpdir ride only here).
|
|
36
|
+
#
|
|
37
|
+
# == The two flows
|
|
38
|
+
#
|
|
39
|
+
# A *non-editable* request (bash, write) gets +(y/n)?+: +y+ → {Approved},
|
|
40
|
+
# +n+ → {Rejected} (after an optional reason), EOF → {Rejected}, else
|
|
41
|
+
# re-prompt.
|
|
42
|
+
#
|
|
43
|
+
# An *editable* request (the +agent+ task) also offers +e+ to edit, and
|
|
44
|
+
# the warning set gates the pre-fill: the payload sent onward is RAW bytes
|
|
45
|
+
# but the terminal must *display* sanitized bytes, so pre-filling an
|
|
46
|
+
# editable buffer with raw bytes would reintroduce the control-byte attack.
|
|
47
|
+
# So a *clean* task offers +[y] approve [e] edit [n] reject+ (+e+ opens
|
|
48
|
+
# the raw task editable), a *flagged* task drops bare-approve and forces a
|
|
49
|
+
# retype: +[e] edit [n] reject+. Honest limit: a terminal can't
|
|
50
|
+
# *structurally* forbid a rubber-stamp +y+ on a clean task — the pure
|
|
51
|
+
# no-rubber-stamp author seat is a richer-UI invariant; the warning-gated
|
|
52
|
+
# retype is the terminal's partial answer.
|
|
53
|
+
#
|
|
54
|
+
# == Sharing
|
|
55
|
+
#
|
|
56
|
+
# +P_one_agent+, and no lock is coming: this is the demo chrome, and one
|
|
57
|
+
# script is one agent. Two agents prompting at once would interleave their
|
|
58
|
+
# blocks on one terminal, and whichever +gets+ runs first reads the answer
|
|
59
|
+
# — so agent A could consume the +y+ meant for agent B and approve a
|
|
60
|
+
# command nobody read. There is no state to corrupt; the contended
|
|
61
|
+
# resource is the terminal and the human at it.
|
|
62
|
+
#
|
|
63
|
+
# That queue belongs to whoever owns the screen, and it needs more than a
|
|
64
|
+
# mutex — it has to say *which* agent is asking. Which is the same
|
|
65
|
+
# sentence as the paragraph at the top: a multi-agent host writes its own
|
|
66
|
+
# confirmer.
|
|
67
|
+
class Terminal < Confirmer
|
|
68
|
+
LOGGER = Pikuri.logger_for('Workspace::Confirmer')
|
|
69
|
+
|
|
70
|
+
# Maximum diff lines rendered before truncation — keeps a
|
|
71
|
+
# whole-file rewrite from scrolling the terminal off-screen.
|
|
72
|
+
DIFF_MAX_LINES = 300
|
|
73
|
+
|
|
74
|
+
# @param request [Request]
|
|
75
|
+
# @return [Approved, Rejected]
|
|
76
|
+
def ask(request:)
|
|
77
|
+
warnings = render(request)
|
|
78
|
+
if request.editable && !request.detail.nil?
|
|
79
|
+
ask_editable(request, clean: warnings.empty?)
|
|
80
|
+
else
|
|
81
|
+
ask_plain(request)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Print the sanitized question / detail / warning block.
|
|
88
|
+
#
|
|
89
|
+
# @param request [Request]
|
|
90
|
+
# @return [Array<Pikuri::Sanitizer::Warning>] the flagged warnings
|
|
91
|
+
# (empty when clean) — the caller gates the edit pre-fill on this.
|
|
92
|
+
def render(request)
|
|
93
|
+
question = Pikuri::Sanitizer.sanitize(request.question)
|
|
94
|
+
detail = request.detail ? Pikuri::Sanitizer.sanitize(request.detail) : nil
|
|
95
|
+
raw_diff = request.change ? unified_diff(request.change) : nil
|
|
96
|
+
diff = raw_diff ? Pikuri::Sanitizer.sanitize(raw_diff) : nil
|
|
97
|
+
warnings = question.warnings + (detail ? detail.warnings : []) + (diff ? diff.warnings : [])
|
|
98
|
+
|
|
99
|
+
puts
|
|
100
|
+
unless warnings.empty?
|
|
101
|
+
puts Rainbow('⚠ Suspicious content detected — read carefully before approving:').yellow.bold
|
|
102
|
+
warnings.each { |w| puts Rainbow(" ! #{w.explanation}").yellow }
|
|
103
|
+
end
|
|
104
|
+
puts Rainbow(question.text).bold
|
|
105
|
+
puts Rainbow(detail.text).dimgray if detail
|
|
106
|
+
print_diff(diff.text) if diff
|
|
107
|
+
warnings
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Render a unified diff of +change+ by shelling to +diff -u+ (routed
|
|
111
|
+
# through {Pikuri::Subprocess}; a richer client uses its own diff lib).
|
|
112
|
+
# Old/new bytes go to tempfiles under a self-cleaning dir. Returns the
|
|
113
|
+
# diff capped at {DIFF_MAX_LINES}, or +nil+ on any failure (empty diff,
|
|
114
|
+
# missing +diff+, IO error) — a cosmetic path, so it degrades to "no
|
|
115
|
+
# diff shown" rather than raising.
|
|
116
|
+
#
|
|
117
|
+
# @param change [Change]
|
|
118
|
+
# @return [String, nil]
|
|
119
|
+
def unified_diff(change)
|
|
120
|
+
Dir.mktmpdir('pikuri-diff-') do |dir|
|
|
121
|
+
old_f = File.join(dir, 'old')
|
|
122
|
+
new_f = File.join(dir, 'new')
|
|
123
|
+
out_f = File.join(dir, 'out')
|
|
124
|
+
File.binwrite(old_f, change.old || '')
|
|
125
|
+
File.binwrite(new_f, change.new)
|
|
126
|
+
File.open(out_f, 'w') do |o|
|
|
127
|
+
Pikuri::Subprocess.run(
|
|
128
|
+
'diff', '-u', '--label', "a/#{change.path}", '--label', "b/#{change.path}",
|
|
129
|
+
old_f, new_f, stdin_data: '', stdout: o, chdir: dir
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
text = File.read(out_f)
|
|
133
|
+
return nil if text.empty?
|
|
134
|
+
|
|
135
|
+
lines = text.lines
|
|
136
|
+
return text if lines.size <= DIFF_MAX_LINES
|
|
137
|
+
|
|
138
|
+
lines.first(DIFF_MAX_LINES).join + "… (#{lines.size - DIFF_MAX_LINES} more diff lines)\n"
|
|
139
|
+
end
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
LOGGER.warn("diff render failed (#{e.class}: #{e.message}); confirming without a diff")
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Print a sanitized unified diff, coloring +added+/-removed/@@hunk@@
|
|
146
|
+
# lines. Sanitization ran in {#render}; this only adds color.
|
|
147
|
+
#
|
|
148
|
+
# @param text [String] sanitized diff text
|
|
149
|
+
# @return [void]
|
|
150
|
+
def print_diff(text)
|
|
151
|
+
text.each_line do |line|
|
|
152
|
+
body = line.chomp
|
|
153
|
+
puts(case body[0]
|
|
154
|
+
when '+' then Rainbow(body).green
|
|
155
|
+
when '-' then Rainbow(body).red
|
|
156
|
+
when '@' then Rainbow(body).cyan
|
|
157
|
+
else Rainbow(body).dimgray
|
|
158
|
+
end)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The +(y/n)?+ flow, returning {Approved}/{Rejected} and prompting for a
|
|
163
|
+
# decline reason on +n+.
|
|
164
|
+
#
|
|
165
|
+
# @param request [Request]
|
|
166
|
+
# @return [Approved, Rejected]
|
|
167
|
+
def ask_plain(request)
|
|
168
|
+
puts '(y/n)?'
|
|
169
|
+
$stdout.flush
|
|
170
|
+
loop do
|
|
171
|
+
line = $stdin.gets
|
|
172
|
+
return Rejected.new(reason: nil) if line.nil?
|
|
173
|
+
|
|
174
|
+
answer = line.strip.downcase
|
|
175
|
+
return Approved.new(new_request_detail: request.detail) if answer == 'y' || answer == 'yes'
|
|
176
|
+
return Rejected.new(reason: read_reason) if answer == 'n' || answer == 'no'
|
|
177
|
+
|
|
178
|
+
print 'Please answer y or n: '
|
|
179
|
+
$stdout.flush
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The editable flow: approve-as-is (clean only), edit, or reject.
|
|
184
|
+
#
|
|
185
|
+
# @param request [Request]
|
|
186
|
+
# @param clean [Boolean] whether the payload passed the sanitizer
|
|
187
|
+
# (gates the pre-fill and the bare-approve path)
|
|
188
|
+
# @return [Approved, Rejected]
|
|
189
|
+
def ask_editable(request, clean:)
|
|
190
|
+
puts(clean ? '[y] approve [e] edit [n] reject' : '⚠ retype required — [e] edit [n] reject')
|
|
191
|
+
$stdout.flush
|
|
192
|
+
loop do
|
|
193
|
+
line = $stdin.gets
|
|
194
|
+
return Rejected.new(reason: nil) if line.nil?
|
|
195
|
+
|
|
196
|
+
answer = line.strip.downcase
|
|
197
|
+
if clean && (answer == 'y' || answer == 'yes')
|
|
198
|
+
return Approved.new(new_request_detail: request.detail)
|
|
199
|
+
end
|
|
200
|
+
if answer == 'e' || answer == 'edit'
|
|
201
|
+
return Approved.new(new_request_detail: read_edited(initial: request.detail, prefill: clean))
|
|
202
|
+
end
|
|
203
|
+
return Rejected.new(reason: read_reason) if answer == 'n' || answer == 'no'
|
|
204
|
+
|
|
205
|
+
print(clean ? 'Please answer y, e, or n: ' : 'Please answer e or n: ')
|
|
206
|
+
$stdout.flush
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Read the human's (re)authored task.
|
|
211
|
+
#
|
|
212
|
+
# @param initial [String] the raw payload to pre-fill with
|
|
213
|
+
# @param prefill [Boolean] +true+ places +initial+ in the editable buffer
|
|
214
|
+
# (clean payloads only); +false+ forces a fresh line — the retype for
|
|
215
|
+
# flagged input, which never puts raw bytes on the terminal.
|
|
216
|
+
# @return [String] the edited text (may be empty)
|
|
217
|
+
def read_edited(initial:, prefill:)
|
|
218
|
+
if prefill
|
|
219
|
+
Reline.pre_input_hook = lambda do
|
|
220
|
+
Reline.insert_text(initial)
|
|
221
|
+
Reline.pre_input_hook = nil
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
(Reline.readline('task> ', false) || '').chomp
|
|
225
|
+
ensure
|
|
226
|
+
Reline.pre_input_hook = nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Prompt for an optional decline reason (steering handed back to the
|
|
230
|
+
# model).
|
|
231
|
+
#
|
|
232
|
+
# @return [String, nil] the trimmed reason, or +nil+ for blank / EOF
|
|
233
|
+
def read_reason
|
|
234
|
+
print 'Reason (optional, Enter to skip): '
|
|
235
|
+
$stdout.flush
|
|
236
|
+
line = $stdin.gets
|
|
237
|
+
return nil if line.nil?
|
|
238
|
+
|
|
239
|
+
reason = line.strip
|
|
240
|
+
reason.empty? ? nil : reason
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -1,156 +1,173 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'rainbow'
|
|
4
|
-
|
|
5
3
|
module Pikuri
|
|
6
4
|
module Workspace
|
|
7
|
-
# Port for
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
5
|
+
# Port for resolving a "may I do this?" confirmation to a decision —
|
|
6
|
+
# currently {Pikuri::Code::Bash}, {Write}, and the +agent+ tool's
|
|
7
|
+
# delegation gate. Subclass and implement {#ask}.
|
|
8
|
+
#
|
|
9
|
+
# == The one job: resolve a {Request} to a decision
|
|
10
|
+
#
|
|
11
|
+
# A Confirmer takes a semantic {Request} and returns {Approved} or
|
|
12
|
+
# {Rejected}. That is *all*: it carries **no tool policy** and never
|
|
13
|
+
# classifies the request's *content* (no filesystem reads, no command
|
|
14
|
+
# parsing, no notion of "passive"). Two shipped implementations:
|
|
15
|
+
#
|
|
16
|
+
# * {Terminal} — the human *chrome*: shows the request and reads the answer.
|
|
17
|
+
# In +confirmer/terminal.rb+ so this seam file stays free of rendering deps.
|
|
18
|
+
# Written for pikuri's own single-threaded +bin/+ scripts and best read as
|
|
19
|
+
# the worked example — a TUI or web host implements this seam itself.
|
|
20
|
+
# * {AutoApprove} — the headless stance: ignores the request and
|
|
21
|
+
# blanket-approves (+--yolo+ / dev-container). Request-independent, so it
|
|
22
|
+
# encodes no policy either.
|
|
23
|
+
#
|
|
24
|
+
# Sharing is per implementation, and the two shipped ones differ:
|
|
25
|
+
# {AutoApprove} is +P_stateless+ and shares freely, while {Terminal} is
|
|
26
|
+
# +P_one_agent+ because concurrent agents fight over one human's keystrokes.
|
|
27
|
+
# A confirmer written for a multi-agent host owns that queueing — and owns
|
|
28
|
+
# naming which agent is asking, which is why the queue can't usefully live
|
|
29
|
+
# down here.
|
|
30
|
+
#
|
|
31
|
+
# Content classification ("is this bash command provably passive, skip the
|
|
32
|
+
# prompt?") deliberately does *not* live here — it's a tool concern
|
|
33
|
+
# ({Pikuri::Code::Bash} takes a separate +passive_detector:+ predicate,
|
|
34
|
+
# falling through to this confirmer only for commands it can't pre-approve).
|
|
35
|
+
# Keeping them apart lets one stateless chrome serve every tool.
|
|
36
|
+
#
|
|
37
|
+
# == The return type: {Approved} / {Rejected}
|
|
11
38
|
#
|
|
12
|
-
#
|
|
39
|
+
# {#ask} returns {Approved} (the possibly-edited {Request#detail} to act on)
|
|
40
|
+
# or {Rejected} (an optional decline reason); callers pattern-match:
|
|
13
41
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
42
|
+
# case confirmer.ask(request: request)
|
|
43
|
+
# in Confirmer::Approved(new_request_detail:) then ...act on it...
|
|
44
|
+
# in Confirmer::Rejected(reason:) then ...steer with it...
|
|
45
|
+
# end
|
|
16
46
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# agentic destructiveness analysis).
|
|
21
|
-
# 2. Agentic destructive-or-not classifier — deferred to v2.
|
|
47
|
+
# {#confirm?} is the derived boolean convenience for gates ({Write},
|
|
48
|
+
# {Pikuri::Code::ExitPlanMode}) needing neither the payload nor the reason.
|
|
49
|
+
# Two orthogonal axes ride the richer return:
|
|
22
50
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
51
|
+
# 1. *Editing* (opt-in via {Request#editable}) — when +detail+ is the
|
|
52
|
+
# verbatim actionable payload (the +agent+ task, where +detail == task+),
|
|
53
|
+
# a confirmer may let the human *rewrite* it — "human as author, not
|
|
54
|
+
# approver". Bash's +"$ <command>"+ is a decorated display string, so it
|
|
55
|
+
# stays non-editable.
|
|
56
|
+
# 2. *Reason* (universal) — any decline can carry typed steering ("use the
|
|
57
|
+
# other library") back to the model as the next observation.
|
|
58
|
+
#
|
|
59
|
+
# A +:once+/+:always+ scope knob was *rejected*: it manages decision
|
|
60
|
+
# *fatigue*, a distinct axis, and the long-term answer to fatigue is making
|
|
61
|
+
# confirmations rare (sandboxing, +--yolo+, the +passive_detector+ skip),
|
|
62
|
+
# not smarter approval modes. (An agentic destructive-or-not classifier is a
|
|
63
|
+
# deferred v2.)
|
|
26
64
|
#
|
|
27
65
|
# == Seam discipline
|
|
28
66
|
#
|
|
29
|
-
# Tools
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# text directly; a web client wraps it in HTML-escaping). Tools do *not*
|
|
39
|
-
# call +gets+ / +puts+ directly — same lesson as listeners, keep IO
|
|
40
|
-
# at the seam so a TUI / web client can plug a different
|
|
41
|
-
# implementation in without touching tool code.
|
|
67
|
+
# Tools take a {Confirmer} via constructor and invoke {#ask} with a semantic
|
|
68
|
+
# {Request} — *what* is asked, never *how* it looks. All presentation
|
|
69
|
+
# belongs to the confirmer: color, the answer cue, parsing, the edit
|
|
70
|
+
# affordance, and — security-relevant — neutralizing hostile bytes in
|
|
71
|
+
# LLM-supplied text. The chrome-independent half (escape control bytes, flag
|
|
72
|
+
# bidi/zero-width/homoglyph spoofs) is the shared {Pikuri::Sanitizer}; the
|
|
73
|
+
# medium-specific half stays with the renderer (a terminal prints sanitized
|
|
74
|
+
# text; a web client HTML-escapes). Tools never call +gets+/+puts+ directly
|
|
75
|
+
# — keep IO at the seam so a TUI/web client plugs in without touching tools.
|
|
42
76
|
class Confirmer
|
|
43
|
-
# The semantic payload of one confirmation
|
|
77
|
+
# The semantic payload of one confirmation:
|
|
44
78
|
#
|
|
45
|
-
# * +question+ — one-line headline
|
|
46
|
-
#
|
|
47
|
-
# owns the
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
79
|
+
# * +question+ — one-line headline the tool composes (e.g. +"OK to
|
|
80
|
+
# overwrite foo.rb: 120 → 245 bytes?"+); the caller owns phrasing, the
|
|
81
|
+
# renderer owns the answer cue.
|
|
82
|
+
# * +detail+ — optional preformatted body: the raw bash command, the
|
|
83
|
+
# +agent+ task, +nil+ for {Write}. When +editable+, the exact text the
|
|
84
|
+
# human may rewrite, returned as {Approved#new_request_detail}.
|
|
85
|
+
# * +editable+ — whether the human may edit +detail+ (default +false+);
|
|
86
|
+
# +true+ only when +detail+ is the verbatim payload (the +agent+ task),
|
|
87
|
+
# never a decorated string (bash's +"$ <command>"+).
|
|
52
88
|
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
# additionally HTML-escape in a web client.
|
|
58
|
-
Request = Data.define(:question, :detail) do
|
|
89
|
+
# +question+/+detail+ are RAW LLM-composed text — renderers MUST neutralize
|
|
90
|
+
# them via {Pikuri::Sanitizer} (see {Terminal}), plus HTML-escape in a web
|
|
91
|
+
# client.
|
|
92
|
+
Request = Data.define(:question, :detail, :editable, :change) do
|
|
59
93
|
# @param question [String] one-line headline; caller owns phrasing
|
|
60
|
-
# @param detail [String, nil] optional preformatted body
|
|
61
|
-
|
|
94
|
+
# @param detail [String, nil] optional preformatted body / editable payload
|
|
95
|
+
# @param editable [Boolean] whether the human may rewrite +detail+
|
|
96
|
+
# @param change [Change, nil] optional structured file change; when
|
|
97
|
+
# present a diff-capable chrome renders a diff of it (see {Change}).
|
|
98
|
+
def initialize(question:, detail: nil, editable: false, change: nil)
|
|
62
99
|
super
|
|
63
100
|
end
|
|
64
101
|
end
|
|
65
102
|
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
103
|
+
# A structured file change for a {Write}/{Edit} confirmation: the
|
|
104
|
+
# *semantic* change (path + old bytes + new bytes), not a pre-rendered
|
|
105
|
+
# string, so diff rendering stays the confirmer's job. {Terminal} shells
|
|
106
|
+
# to +diff+; a richer client renders the same {Change} its own way. +old+
|
|
107
|
+
# is the current bytes, or +nil+ for a new file.
|
|
108
|
+
Change = Data.define(:path, :old, :new)
|
|
109
|
+
|
|
110
|
+
# An approval carrying the text to act on: the human-authored (possibly
|
|
111
|
+
# edited) {Request#detail} when editable, else +detail+ verbatim.
|
|
112
|
+
Approved = Data.define(:new_request_detail)
|
|
113
|
+
|
|
114
|
+
# A decline carrying the human's optional steering reason (+nil+ if none),
|
|
115
|
+
# which the tool folds into the observation handed back to the model.
|
|
116
|
+
Rejected = Data.define(:reason)
|
|
117
|
+
|
|
118
|
+
# @param request [Request] semantic content composed by the calling
|
|
119
|
+
# tool. The confirmer renders it (escaping for its medium), poses
|
|
120
|
+
# the question, and parses the answer.
|
|
121
|
+
# @return [Approved, Rejected]
|
|
70
122
|
# @raise [NotImplementedError] in the abstract base
|
|
71
|
-
def
|
|
72
|
-
raise NotImplementedError, "#{self.class}#
|
|
123
|
+
def ask(request:)
|
|
124
|
+
raise NotImplementedError, "#{self.class}#ask must be implemented"
|
|
73
125
|
end
|
|
74
126
|
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# output the +Terminal+ listener may have produced just above):
|
|
78
|
-
#
|
|
79
|
-
# 1. a bold-yellow warning block — one line per
|
|
80
|
-
# {Pikuri::Sanitizer::Warning} — shown only when the sanitizer
|
|
81
|
-
# flagged something suspicious in the question or detail
|
|
82
|
-
# 2. the question, bold
|
|
83
|
-
# 3. the detail, dim — omitted when +nil+
|
|
84
|
-
# 4. the +(y/n)?+ cue
|
|
127
|
+
# Boolean convenience over {#ask} for gates needing neither the edited
|
|
128
|
+
# payload nor the reason.
|
|
85
129
|
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
# carries plain text only).
|
|
130
|
+
# @param request [Request]
|
|
131
|
+
# @return [Boolean] +true+ iff approved
|
|
132
|
+
def confirm?(request:)
|
|
133
|
+
ask(request:).is_a?(Approved)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Whether {#ask} can actually stop and wait for a person — the posture
|
|
137
|
+
# {Pikuri::Trifecta} reads when deciding whether a gated tool reaches the
|
|
138
|
+
# approver seat rather than counting as autonomous egress.
|
|
96
139
|
#
|
|
97
|
-
#
|
|
140
|
+
# +true+ on the base class: a confirmer exists to ask, so a subclass that
|
|
141
|
+
# doesn't must say so, and one that forgets is credited with a gate it
|
|
142
|
+
# does not have only if it also forgets to be {AutoApprove}. Override to
|
|
143
|
+
# +false+ for any request-independent auto-answer.
|
|
98
144
|
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# * EOF / Ctrl+D (+gets+ returns +nil+) → +false+, deliberate abort
|
|
102
|
-
# * anything else (blank, typo, +"maybe"+) → re-prompt with a short
|
|
103
|
-
# "Please answer y or n: " line and loop
|
|
145
|
+
# This is what makes +--no-confirm+ re-harden a wiring's verdict on its
|
|
146
|
+
# own, with nothing in the detector special-casing the flag.
|
|
104
147
|
#
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
# @param request [Request]
|
|
108
|
-
# @return [Boolean]
|
|
109
|
-
def confirm?(request:)
|
|
110
|
-
question = Pikuri::Sanitizer.sanitize(request.question)
|
|
111
|
-
detail = request.detail ? Pikuri::Sanitizer.sanitize(request.detail) : nil
|
|
112
|
-
warnings = question.warnings + (detail ? detail.warnings : [])
|
|
113
|
-
|
|
114
|
-
puts
|
|
115
|
-
unless warnings.empty?
|
|
116
|
-
puts Rainbow('⚠ Suspicious content detected — read carefully before approving:').yellow.bold
|
|
117
|
-
warnings.each { |w| puts Rainbow(" ! #{w.explanation}").yellow }
|
|
118
|
-
end
|
|
119
|
-
puts Rainbow(question.text).bold
|
|
120
|
-
puts Rainbow(detail.text).dimgray if detail
|
|
121
|
-
puts '(y/n)?'
|
|
122
|
-
$stdout.flush
|
|
123
|
-
loop do
|
|
124
|
-
line = $stdin.gets
|
|
125
|
-
return false if line.nil?
|
|
126
|
-
|
|
127
|
-
answer = line.strip.downcase
|
|
128
|
-
return true if answer == 'y' || answer == 'yes'
|
|
129
|
-
return false if answer == 'n' || answer == 'no'
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def blocks_on_human? = true
|
|
130
150
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Approves everything. Used by +bin/pikuri-code --yolo+ (docker /
|
|
138
|
-
# dev-container mode) and by tool specs that don't want to
|
|
139
|
-
# coordinate stdin. The name +AUTO_APPROVE+ matches the public
|
|
140
|
-
# constant {AUTO_APPROVE}.
|
|
151
|
+
# Approves everything unedited — the headless "opted out of dialogs"
|
|
152
|
+
# stance. Request-independent, so it classifies nothing and stays a
|
|
153
|
+
# legitimate Confirmer. Used by +bin/pikuri-code --yolo+, downstream hosts'
|
|
154
|
+
# yolo modes, and specs that don't want to coordinate stdin.
|
|
141
155
|
class AutoApprove < Confirmer
|
|
142
|
-
# @param request [Request]
|
|
143
|
-
# @return [
|
|
144
|
-
def
|
|
145
|
-
|
|
156
|
+
# @param request [Request]
|
|
157
|
+
# @return [Approved] always, carrying +detail+ unchanged
|
|
158
|
+
def ask(request:)
|
|
159
|
+
Approved.new(new_request_detail: request.detail)
|
|
146
160
|
end
|
|
147
|
-
end
|
|
148
161
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
162
|
+
# @return [Boolean] +false+ — nobody is asked, so a tool gated by this
|
|
163
|
+
# confirmer holds autonomous egress however human-gated its wiring
|
|
164
|
+
# looks on paper
|
|
165
|
+
def blocks_on_human? = false
|
|
166
|
+
end
|
|
152
167
|
|
|
153
|
-
# Shared singleton
|
|
168
|
+
# Shared stateless {AutoApprove} singleton, reusable across tools,
|
|
169
|
+
# sub-agents, and hosts' yolo modes. (No +TERMINAL+ singleton: {Terminal}
|
|
170
|
+
# is a demo chrome, and real front-ends bring their own.)
|
|
154
171
|
AUTO_APPROVE = AutoApprove.new
|
|
155
172
|
end
|
|
156
173
|
end
|