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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3918af9bb28c07c5706f2738899b05cda0a9c65a3a59b7e10799a98c8b8bc0b8
4
- data.tar.gz: 142a335f7f42891c0f9f5cd9964dc31202691f1fa470fef526ad61280a2f8bff
3
+ metadata.gz: 120a595848652290fe60f2505a6cf74ff7d8f83764da5a22b89c7f71c95b5796
4
+ data.tar.gz: cb6348c77246df64f29b03cdb6d846abbd4bfa97f9c4830c155fd978ea1b65cb
5
5
  SHA512:
6
- metadata.gz: a20b8468fa2fc9cbed1aed620b645443cc6fb8969aad611de24e8620c88efe16b692265aabb5520d0c5231f5f6da2c31a0cae3f815eed2fd4e7840ef13e637d5
7
- data.tar.gz: b5c6f5bc61a0f45641127a252c5f55bac9e0d3f5bb5ee577abeed31419859454daf7b7db26a0f5880cd638304ab3bd7da5d63b910093c4223372f21b5a97f2cb
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 + `AUTO_APPROVE` /
12
- `TERMINAL` for user-state mutations.
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
- confirmer = Pikuri::Workspace::Confirmer::TERMINAL
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 8 of the pikuri guide](../docs/guide/08-code.md) — what
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 asking the user to confirm a potentially destructive tool
8
- # operation — currently {Pikuri::Code::Bash} (every command) and
9
- # {Write} (overwrite of an existing file with non-identical
10
- # content). Subclass and implement {#confirm?}.
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
- # == Why a Boolean return
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
- # v1 returns +true+ or +false+. Two paths-not-taken worth recording
15
- # so a future reader knows the design space was considered:
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
- # 1. Richer return (+:once+ / +:always+ / +:reject+) — rejected
18
- # because it creates decision fatigue, and the long-term answer
19
- # is to make confirmations rare rather than smart (sandboxing,
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
- # The intended escape from confirmation prompts today is sandboxing
24
- # (docker / dev-container) plus the +--yolo+ flag on +bin/pikuri-code+
25
- # (which wires {AUTO_APPROVE} instead of {TERMINAL}).
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 that need confirmation take a {Confirmer} via constructor and
30
- # invoke {#confirm?} with a semantic {Request} — *what* is being
31
- # asked, never *how* it should look. All presentation belongs to the
32
- # confirmer implementation: color, the answer cue, answer parsing,
33
- # and security-relevant neutralizing hostile bytes in
34
- # LLM-supplied text. The chrome-independent half of that (escape
35
- # control bytes, flag bidi / zero-width / homoglyph spoofs) is the
36
- # shared {Pikuri::Sanitizer}; the medium-specific half stays with the
37
- # renderer that knows its medium (a terminal prints the sanitized
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. Two fields:
77
+ # The semantic payload of one confirmation:
44
78
  #
45
- # * +question+ — one-line headline composed by the calling tool,
46
- # e.g. +"OK to overwrite foo.rb: 120 → 245 bytes?"+. The caller
47
- # owns the phrasing and punctuation; the renderer owns the
48
- # answer cue (+"(y/n)?"+, buttons, ...).
49
- # * +detail+ optional preformatted body, possibly multi-line:
50
- # the raw bash command for {Pikuri::Code::Bash}, +nil+ for
51
- # {Write}. Renderers typically display it monospaced / dimmed.
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
- # Both fields are RAW, straight from tool arguments the LLM
54
- # composed (+detail+ is the command verbatim; +question+ may embed
55
- # an LLM-written description). Renderers MUST neutralize them before
56
- # display route through {Pikuri::Sanitizer} (see {Terminal}), and
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
- def initialize(question:, detail: nil)
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
- # @param request [Request] semantic content composed by the
67
- # calling tool. The confirmer renders it (escaping for its
68
- # medium), poses the question, and parses the answer.
69
- # @return [Boolean] +true+ iff approved
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 confirm?(request:)
72
- raise NotImplementedError, "#{self.class}#confirm? must be implemented"
123
+ def ask(request:)
124
+ raise NotImplementedError, "#{self.class}#ask must be implemented"
73
125
  end
74
126
 
75
- # Stdin/stdout implementation. Renders the request as up to several
76
- # lines (a leading +puts+ guarantees separation from any streamed
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
- # Both question and detail pass through {Pikuri::Sanitizer}, which
87
- # neutralizes control bytes without it, a model could craft a
88
- # command or description containing +"\rrm -rf ~/"+ that visually
89
- # overwrites the echoed line after the user has already read it —
90
- # and reports *why* it was unsafe so the user reads the warning
91
- # before answering. Colors come from Rainbow (already in the
92
- # dependency closure via pikuri-core), which self-disables on
93
- # non-TTY output; the bold-yellow warning rendering is this
94
- # terminal chrome's call, not the sanitizer's (the +Warning+
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
- # Then reads one line from +$stdin+ and parses it strictly:
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
- # * +"y"+ / +"yes"+ (case-insensitive, stripped) +true+
100
- # * +"n"+ / +"no"+ +false+
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
- # No retry cap; EOF eventually breaks adversarial input.
106
- class Terminal < Confirmer
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
- print 'Please answer y or n: '
132
- $stdout.flush
133
- end
134
- end
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] ignored
143
- # @return [true]
144
- def confirm?(request:)
145
- true
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
- # Shared singleton instance of {Terminal}. Stateless; reusable
150
- # across tools and sub-agents.
151
- TERMINAL = Terminal.new
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 instance of {AutoApprove}.
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