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.
@@ -1,84 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rainbow'
4
-
5
3
  module Pikuri
6
4
  module Code
7
5
  # The +bash+ tool — run an arbitrary shell command in the workspace.
8
- # Instantiating +Code::Bash.new(workspace: ws, confirmer: c)+ produces
9
- # a tool whose {Pikuri::Tool#to_ruby_llm_tool} wiring is identical to
10
- # any bundled tool's. Same shape as {Pikuri::Workspace::Write}
11
- # (workspace + confirmer captured by the +execute+ closure at
12
- # construction).
6
+ # +Code::Bash.new(filesystem: fs, confirmer: c)+ produces a tool whose
7
+ # {Pikuri::Tool#to_ruby_llm_tool} wiring is identical to any bundled tool's
8
+ # (filesystem + confirmer captured by the +execute+ closure).
13
9
  #
14
10
  # == Confirmation
15
11
  #
16
- # Every command requires confirmation. Bash composes the full prompt
17
- # (header line, +$ <command>+ echo, +(y/n)?+ cue) and hands it to the
18
- # confirmer; the confirmer renders + parses the answer. The command
19
- # echo passes through {.visible} which escapes control bytes so the
20
- # model can't smuggle a +\r\033[2K rm -rf ~/+ behind the displayed
21
- # text. Execution uses the raw command; only the *display* is
22
- # sanitized.
12
+ # Every command is confirmed (unless a +passive_detector+ pre-approves it).
13
+ # Bash composes a semantic {Pikuri::Workspace::Confirmer::Request} a
14
+ # question plus +$ <command>+ detail and hands it to the confirmer, which
15
+ # owns ALL presentation and medium-appropriate escaping of the raw bytes
16
+ # (terminal neutralizes control bytes, web client HTML-escapes). The request
17
+ # carries the command verbatim so each renderer escapes for its own medium.
18
+ # The *observation* echo (+$ ...+ in the tool result) passes through
19
+ # {.visible}, so the model can't smuggle a +\r\033[2K rm -rf ~/+ behind it
20
+ # either. Execution uses the raw command; only displays are sanitized.
23
21
  #
24
22
  # == Subprocess wiring
25
23
  #
26
- # The command runs through {Pikuri::Subprocess.spawn} with argv:
27
- #
28
24
  # timeout --signal=TERM --kill-after=5s <timeout>s bash -c <command>
29
25
  #
30
- # +bash -c+ (no +-l+) — no profile/rc sourcing, no inherited login
31
- # environment beyond what pikuri itself runs in. +timeout(1)+ from
32
- # GNU coreutils handles the SIGTERM-then-SIGKILL race; we never use
33
- # Ruby's +Timeout.timeout+ (can't reliably kill subprocesses). The
34
- # +--kill-after=5s+ gives the command 5 seconds to handle SIGTERM
35
- # cleanly before SIGKILL is sent.
26
+ # +bash -c+ (no +-l+) — no profile/rc sourcing. +timeout(1)+ from GNU
27
+ # coreutils handles the SIGTERM-then-SIGKILL race (Ruby's +Timeout.timeout+
28
+ # can't reliably kill subprocesses); +--kill-after=5s+ gives 5s to handle
29
+ # SIGTERM before SIGKILL. The environment is de-bundlerized first, or a
30
+ # +bundle exec+ against another project would pick up *pikuri's* Gemfile.
31
+ # See {.subprocess_env} and {Pikuri::BundlerEnv}.
36
32
  #
37
33
  # == Timeout detection
38
34
  #
39
- # GNU coreutils' +timeout+ exits +124+ after a successful SIGTERM,
40
- # +137+ after escalating to SIGKILL. We treat both as "command timed
41
- # out". A third code, +125+, is also accepted: uutils-coreutils 0.2.2
42
- # (the Rust reimplementation shipped on some distros) sends the
43
- # SIGTERM correctly but then mis-reports +125+ ("timeout itself
44
- # failed") instead of +124+ whenever +--kill-after+ is in play. False-
45
- # positive risk on real GNU coreutils is low — our argv is fixed and
46
- # well-formed, so a +125+ there would indicate a misconfigured PATH
47
- # rather than a routine timeout.
48
- #
49
- # Caveat: +137+ is ambiguous — a command killed by the OOM-killer
50
- # also exits +137+. v1 accepts the mis-classification; the
51
- # observation tells the user "sent SIGTERM, then SIGKILL" regardless.
35
+ # GNU +timeout+ exits +124+ after SIGTERM, +137+ after escalating to
36
+ # SIGKILL; both are treated as "timed out". +125+ is also accepted:
37
+ # uutils-coreutils 0.2.2 (the Rust reimplementation on some distros)
38
+ # mis-reports +125+ instead of +124+ when +--kill-after+ is in play.
39
+ # False-positive risk on real GNU coreutils is low (fixed, well-formed
40
+ # argv). Caveat: +137+ is ambiguous — the OOM-killer also exits +137+; v1
41
+ # accepts the mis-classification (the observation says "sent SIGTERM, then
42
+ # SIGKILL" regardless).
52
43
  #
53
44
  # == Output handling
54
45
  #
55
- # Combined stdout+stderr (popen2e). Head+tail truncation at
56
- # {OUTPUT_HEAD} + {OUTPUT_TAIL} bytes with a marker reporting both
57
- # bytes-omitted and the original total the model needs the scale
58
- # to decide whether to re-run with +head+/+tail+/+grep+.
46
+ # Combined stdout+stderr (popen2e), head+tail truncated at {OUTPUT_HEAD} +
47
+ # {OUTPUT_TAIL} bytes with a marker reporting bytes-omitted and total — the
48
+ # model needs the scale to decide whether to re-run with +head+/+grep+.
59
49
  #
60
50
  # == Backgrounded subprocesses
61
51
  #
62
- # Plain +cmd &+ does NOT detach — the backgrounded child inherits our
63
- # combined-output pipe by default, so {Pikuri::Subprocess#wait} blocks
64
- # on +io.read+ until the backgrounded process exits. The model must
65
- # redirect fds away from our pipe to genuinely background:
66
- # +cmd >/dev/null 2>&1 &+. Such commands stay in our pgroup and get
67
- # SIGTERM on pikuri exit via {Pikuri::Subprocess.cleanup!}; +nohup+ /
68
- # +setsid+ plus redirection opt out of cleanup.
52
+ # Plain +cmd &+ does NOT detach — the child inherits our combined-output
53
+ # pipe, so {Pikuri::Subprocess#wait} blocks on +io.read+ until it exits. The
54
+ # model must redirect fds to genuinely background: +cmd >/dev/null 2>&1 &+.
55
+ # Such commands stay in our pgroup and get SIGTERM on pikuri exit via
56
+ # {Pikuri::Subprocess.cleanup!}; +nohup+ / +setsid+ plus redirection opt out.
69
57
  #
70
- # == Refusals
58
+ # == Sharing
71
59
  #
72
- # All returned as +"Error: ..."+ observations:
60
+ # +P_one_agent+, but only because of the {Pikuri::Workspace::Confirmer}: with
61
+ # a shared {Confirmer::Terminal} two agents fight over one human's
62
+ # keystrokes. In substance this tool is stateless — every call spawns its
63
+ # own subprocess, and the {Pikuri::Workspace::Filesystem}, {Bash::Sandbox}
64
+ # and +passive_detector+ it holds are immutable — so under
65
+ # {Confirmer::AutoApprove} nothing stops one instance serving every agent.
73
66
  #
74
- # * Empty / whitespace-only +command+ fast reject before confirming.
75
- # * +timeout+ outside +[1, MAX_TIMEOUT]+ bounds error.
76
- # * User declined the confirmation → +"Error: user declined ..."+.
77
- # * +timeout+ exit (+124+ / +137+) → timeout error with partial output.
67
+ # Concurrency the *commands* create is not pikuri's to serialize: ten agents
68
+ # running +bundle install+ in one workspace will corrupt each other's work
69
+ # exactly as ten shells would.
78
70
  class Bash < Pikuri::Tool
79
- # Pikuri-convention per-module logger; the +Bash+ progname tags the
80
- # construction-time warning below so it's clear in the shared
81
- # +Pikuri.log_io+ stream which tool issued it.
82
71
  # @return [Logger]
83
72
  LOGGER = Pikuri.logger_for('Bash')
84
73
 
@@ -92,6 +81,29 @@ module Pikuri
92
81
  # passed to +timeout --kill-after=...+.
93
82
  KILL_AFTER = '5s'
94
83
 
84
+ # Git config keys forced onto every git invocation, threaded in via
85
+ # {.git_hardening_delta}. What each key does:
86
+ #
87
+ # * +core.fsmonitor=false+ — the headline vector: a repo-local
88
+ # +core.fsmonitor = <cmd>+ runs +<cmd>+ on every index refresh (plain
89
+ # +git status+/+diff+, no +.gitattributes+ needed). +false+ is git's
90
+ # default, so legitimate status/diff are unaffected.
91
+ # * +core.pager=cat+ — belt-and-suspenders; auto-paging is already inert
92
+ # under our non-TTY pipe, but a forced +cat+ makes it a no-op regardless.
93
+ #
94
+ # Why this exists — the config-execution vector that makes even a *passive*
95
+ # git command an unconfirmed RCE without it, the narrow diff-driver
96
+ # residual it deliberately does NOT close (+diff.external+ /
97
+ # +.gitattributes+ +.textconv+, uncloseable via environment), and the tie
98
+ # to {PassiveCommandDetector}'s +allow_git:+ — lives in
99
+ # +pikuri-code/DESIGN.md+ (*Passive git needs Bash's hardening*).
100
+ #
101
+ # @return [Array<Array(String, String)>] +[key, value]+ pairs.
102
+ GIT_HARDENING = [
103
+ ['core.fsmonitor', 'false'],
104
+ ['core.pager', 'cat']
105
+ ].freeze
106
+
95
107
  # @return [Integer] bytes preserved from the start of the output
96
108
  # when the combined-output stream exceeds {OUTPUT_HEAD} + {OUTPUT_TAIL}.
97
109
  OUTPUT_HEAD = 15 * 1024
@@ -103,52 +115,53 @@ module Pikuri
103
115
  # bullets. Per-parameter constraints (default, max) live in the
104
116
  # parameter descriptions.
105
117
  #
118
+ # The +Avoid ... cat / rg / find+ bullet is load-bearing, not etiquette —
119
+ # a shell read skips the +cat -n+ numbering {Pikuri::Workspace::Edit}
120
+ # anchors on, the byte caps, the read-before-edit ledger and the path
121
+ # gate. (An outside-model review — Grok 4.6, 2026-08 — called the split
122
+ # the right bet; nothing tests that the model obeys the bullet.)
123
+ #
106
124
  # @return [String]
107
125
  DESCRIPTION = <<~DESC
108
126
  Run a bash command in the workspace.
109
127
 
110
128
  Usage:
111
129
  - Use for tasks the dedicated tools can't do: git, tests, package managers, multi-step shell pipelines.
112
- - Prefer `read` / `write` / `edit` / `grep` / `glob` over `cat` / `sed` / `rg` / `find` they're workspace-resolved.
130
+ - IMPORTANT: Avoid using this tool to run `cat`, plain `head` or `tail`, `sed`, `awk`, `rg`, `find` or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user, respect the workspace and produce cleaner output.
113
131
  - Working directory is ALWAYS the project root: `pwd` returns it, and relative paths in commands resolve from there. To operate in a subfolder, chain `cd` in the same command (`cd src/foo && make test`) — `cd` does NOT persist across calls; each call starts fresh at the project root.
114
132
  - stdin is closed; interactive commands hang until timeout. Use non-interactive flags (`apt -y`, `git commit -m`).
115
133
  - Plain `cmd &` does NOT detach — the backgrounded process inherits our output pipe and blocks. To genuinely background, redirect fds: `cmd >/dev/null 2>&1 &`. Add `nohup` or `setsid` to survive pikuri exit.
116
134
  - Combined stdout+stderr is returned. Suppress either via `2>/dev/null` etc.
117
135
  - Large outputs are head+tail-truncated. Pipe through `head`/`tail`/`grep`/`wc` to control volume.
118
- - Non-zero exit status is a normal observation, not an error.
119
- - The user must confirm every command before it runs; on rejection an Error is returned.
136
+ # Git
137
+ - Interactive flags (`-i`, e.g. `git rebase -i`, `git add -i`) are not supported in this environment.
138
+ - Use the `gh` CLI (if available) for GitHub operations (PRs, issues, API).
139
+ - Commit or push only when the user asks.
120
140
  DESC
121
141
 
122
- # @param workspace [Pikuri::Workspace::Filesystem] captured for
123
- # +chdir+; commands run in +workspace.project_root+, always.
124
- # Even when the surrounding Ruby process has already chdir'd
125
- # to the project root (e.g. +bin/pikuri-code+ does this at
126
- # startup), Bash still passes the explicit +chdir:+ so a
127
- # sub-agent, future host, or anyone embedding Bash directly
128
- # gets the same predictable cwd. Bash does NOT path-resolve
129
- # individual arguments the +command+ string is opaque shell
130
- # syntax.
131
- # @param confirmer [Pikuri::Workspace::Confirmer] consulted before
132
- # every command.
133
- # @param sandbox [Code::Bash::Sandbox] filesystem-sandbox seam
134
- # (default {Sandbox::NONE} identity passthrough). Pass
135
- # {Sandbox::Bubblewrap.new(workspace: workspace)+} for an
136
- # isolated subprocess whose filesystem view is the workspace's
137
- # readable/writable roots plus the OS-runtime baseline. See
138
- # {Sandbox} for the rationale.
139
- # @raise [RuntimeError] if +bash+ or +timeout+ aren't on +PATH+;
140
- # fail-loud at construction rather than the first tool call.
142
+ # @param filesystem [Pikuri::Workspace::Filesystem] captured for +chdir+;
143
+ # commands always run in +filesystem.project_root+ (explicit +chdir:+
144
+ # even when the process already chdir'd there, so a sub-agent or
145
+ # embedding host gets the same cwd). Bash does NOT path-resolve
146
+ # arguments the +command+ is opaque shell syntax.
147
+ # @param confirmer [Pikuri::Workspace::Confirmer] consulted before every
148
+ # command that +passive_detector+ does not pre-approve.
149
+ # @param passive_detector [#passive?, nil] optional predicate over the
150
+ # raw command string ({PassiveCommandDetector}); +true+ runs with no
151
+ # prompt, +false+/+nil+ falls through to +confirmer+. The
152
+ # passive-auto-gate seam, kept separate so "how to ask" ({Confirmer})
153
+ # and "what may skip asking" (this) stay composable axes.
154
+ # @param sandbox [Code::Bash::Sandbox] filesystem-sandbox seam (default
155
+ # {Sandbox::NONE}). Pass {Sandbox::Bubblewrap.new(filesystem:)} for an
156
+ # isolated subprocess; see {Sandbox}.
157
+ # @raise [RuntimeError] if +bash+ or +timeout+ aren't on +PATH+.
141
158
  # @return [Bash]
142
- def initialize(workspace:, confirmer:, sandbox: Sandbox::NONE)
159
+ def initialize(filesystem:, confirmer:, sandbox: Sandbox::NONE, passive_detector: nil)
143
160
  Bash.send(:check_binaries!)
144
- # Capability advisory when no sandbox is in play. Without a
145
- # sandbox, +bash+ runs with pikuri's own UID + filesystem view —
146
- # anything readable to the user is readable to the LLM via +cat
147
- # ~/.ssh/id_*+ / +aws configure list+ / etc. The per-command
148
- # +Confirmer+ is the only line of defense in that mode. The
149
- # bundled {Sandbox::Bubblewrap} addresses this concern with a
150
- # filesystem-restricted subprocess; warn only when the host has
151
- # opted out (or never opted in).
161
+ # Without a sandbox, bash runs with pikuri's UID + filesystem view —
162
+ # anything the user can read the LLM can read (+cat ~/.ssh/id_*+, +aws
163
+ # configure list+), with the per-command Confirmer the only defense. Warn
164
+ # only when the host opted out (or never opted in); Bubblewrap fixes it.
152
165
  if sandbox.equal?(Sandbox::NONE)
153
166
  LOGGER.warn(
154
167
  'Code::Bash is unsandboxed: commands run under your UID and can read ' \
@@ -171,9 +184,31 @@ module Pikuri
171
184
  "max #{MAX_TIMEOUT}, e.g. 300."
172
185
  },
173
186
  execute: ->(command:, description: nil, timeout: DEFAULT_TIMEOUT) {
174
- Bash.run(workspace: workspace, confirmer: confirmer, sandbox: sandbox,
187
+ Bash.run(filesystem: filesystem, confirmer: confirmer, sandbox: sandbox,
188
+ passive_detector: passive_detector,
175
189
  command: command, description: description, timeout: timeout)
176
- }
190
+ },
191
+ # Both inbound axes come from the *sandbox*, not the workspace: the
192
+ # workspace governs what the LLM observes through the file tools, the
193
+ # sandbox governs what bash sees. Scope the workspace to a vouched-for
194
+ # repo and +cat ~/Downloads/*+ still works under a full-root bind.
195
+ #
196
+ # Note what this does *not* claim: +:human_reviewed+, though a
197
+ # +Confirmer+ is right there. The human approves a *command* — a
198
+ # program, not a payload. Behind a pipe, a heredoc, +$(…)+ or a script
199
+ # file, the bytes that egress are computed at runtime and were never on
200
+ # screen, so approving +curl+ approves an intent. That failure of the
201
+ # payload-visibility clause is what keeps a coding agent over a private
202
+ # repo loud, and it is deliberately a literal here rather than
203
+ # something a rule engine could talk itself out of.
204
+ #
205
+ # The destination axis is left to derive: a shell picks its own host,
206
+ # so a live leg here is always +:attacker_reachable+.
207
+ trifecta_legs: Pikuri::Tool::TrifectaLegs.new(
208
+ private: filesystem.private?,
209
+ untrusted: sandbox.confined_to_workspace? && filesystem.trusted? ? :none : :hard,
210
+ egress_payload_review: sandbox.egress? ? :unreviewed : :no_egress
211
+ )
177
212
  )
178
213
  end
179
214
 
@@ -181,28 +216,42 @@ module Pikuri
181
216
  # either +"$ ...\n<out>\n\nexit status: N"+ on a normal exit, or
182
217
  # +"Error: ..."+ on rejection / timeout / bad inputs.
183
218
  #
184
- # @param workspace [Pikuri::Workspace::Filesystem]
219
+ # @param filesystem [Pikuri::Workspace::Filesystem]
185
220
  # @param confirmer [Pikuri::Workspace::Confirmer]
186
- # @param sandbox [Code::Bash::Sandbox] wraps the spawned argv;
187
- # {Sandbox::NONE} for identity passthrough,
188
- # {Sandbox::Bubblewrap} for filesystem isolation.
221
+ # @param sandbox [Code::Bash::Sandbox] wraps the spawned argv.
222
+ # @param passive_detector [#passive?, nil] passive-command pre-approval
223
+ # predicate; a +true+ verdict skips the prompt. See {#initialize}.
189
224
  # @param command [String] raw command as supplied by the LLM
190
225
  # @param description [String, nil] optional short label for the user
191
226
  # @param timeout [Integer] seconds before SIGTERM is sent
192
227
  # @return [String]
193
- def self.run(workspace:, confirmer:, command:, description:, timeout:, sandbox: Sandbox::NONE)
228
+ def self.run(filesystem:, confirmer:, command:, description:, timeout:,
229
+ sandbox: Sandbox::NONE, passive_detector: nil)
194
230
  return 'Error: empty bash command.' if command.strip.empty?
195
231
  return "Error: timeout must be >= 1, got #{timeout}" if timeout < 1
196
232
  return "Error: timeout must be <= #{MAX_TIMEOUT}, got #{timeout}" if timeout > MAX_TIMEOUT
197
233
 
198
- prompt = compose_prompt(command: command, description: description, timeout: timeout)
199
- return 'Error: user declined the bash command.' unless confirmer.confirm?(prompt: prompt)
234
+ # A command the detector deems passive (provably observe-only) runs
235
+ # without a prompt; everything else is confirmed by the human.
236
+ unless passive_detector&.passive?(command)
237
+ request = compose_request(command: command, description: description, timeout: timeout)
238
+ case confirmer.ask(request: request)
239
+ in Pikuri::Workspace::Confirmer::Rejected(reason:)
240
+ msg = +'Error: user declined the bash command.'
241
+ msg << " Reason: #{reason}" if reason && !reason.empty?
242
+ return msg
243
+ in Pikuri::Workspace::Confirmer::Approved
244
+ # fall through to execution (the command is non-editable, so the
245
+ # approved detail is the same command we already hold)
246
+ end
247
+ end
200
248
 
201
249
  argv = sandbox.wrap([
202
250
  'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s",
203
251
  'bash', '-c', command
204
252
  ])
205
- result = Pikuri::Subprocess.spawn(*argv, chdir: workspace.project_root.to_s, env: workspace.env).wait
253
+ result = Pikuri::Subprocess.spawn(*argv, chdir: filesystem.project_root.to_s,
254
+ env: subprocess_env(filesystem)).wait
206
255
 
207
256
  output = truncate(result.output)
208
257
  exit_code = result.status.exitstatus
@@ -217,41 +266,81 @@ module Pikuri
217
266
  end
218
267
  end
219
268
 
220
- # Compose the multi-line confirmation prompt. Three lines:
269
+ # Compose the semantic confirmation request:
221
270
  #
222
- # 1. +OK to run bash[: <desc>][ \[Timeout: Ns\]]+ (bold)
223
- # 2. +$ <visible(command)>+ (dim)
224
- # 3. +(y/n)?+
271
+ # * question — +OK to run bash[: <desc>][ \[Timeout: Ns\]]+
272
+ # * detail — +$ <command>+, the command VERBATIM (the confirmer
273
+ # escapes for its medium; see the class header's Confirmation
274
+ # section)
225
275
  #
226
276
  # Colon after +bash+ is dropped when there's no description, since a
227
277
  # trailing colon with nothing after it reads as broken. Timeout
228
278
  # suffix appears only when non-default.
229
279
  #
230
- # @return [String]
231
- def self.compose_prompt(command:, description:, timeout:)
232
- header = +'OK to run bash'
280
+ # @return [Pikuri::Workspace::Confirmer::Request]
281
+ def self.compose_request(command:, description:, timeout:)
282
+ question = +'OK to run bash'
233
283
  desc_clean = description&.strip
234
- header << ": #{desc_clean}" if desc_clean && !desc_clean.empty?
235
- header << " [Timeout: #{timeout}s]" if timeout != DEFAULT_TIMEOUT
284
+ question << ": #{desc_clean}" if desc_clean && !desc_clean.empty?
285
+ question << " [Timeout: #{timeout}s]" if timeout != DEFAULT_TIMEOUT
286
+
287
+ Pikuri::Workspace::Confirmer::Request.new(question: question, detail: "$ #{command}")
288
+ end
289
+ private_class_method :compose_request
290
+
291
+ # Environment for the bash subprocess: the de-bundlerized base
292
+ # ({Pikuri::BundlerEnv.clean_delta}), then git hardening
293
+ # ({.git_hardening_delta}), then the filesystem's git identity
294
+ # ({Pikuri::Workspace::Filesystem#env}). The three deltas touch disjoint
295
+ # keys (bundler vars / +GIT_CONFIG_*+ / +GIT_AUTHOR_*+ +
296
+ # +GIT_COMMITTER_*+), so merge order is immaterial.
297
+ #
298
+ # @param filesystem [Pikuri::Workspace::Filesystem]
299
+ # @return [Hash{String=>(String,nil)}] +env:+ delta for
300
+ # {Pikuri::Subprocess.spawn} (a +nil+ value unsets the key).
301
+ def self.subprocess_env(filesystem)
302
+ Pikuri::BundlerEnv.clean_delta.merge(git_hardening_delta).merge(filesystem.env)
303
+ end
304
+ private_class_method :subprocess_env
236
305
 
237
- [
238
- Rainbow(header).bold,
239
- Rainbow("$ #{visible(command)}").dimgray,
240
- '(y/n)?'
241
- ].join("\n")
306
+ # Emit {GIT_HARDENING} as a +GIT_CONFIG_COUNT+ / +GIT_CONFIG_KEY_<n>+ /
307
+ # +GIT_CONFIG_VALUE_<n>+ env delta. This protocol has the same top
308
+ # precedence as command-line +-c+, so it overrides a hostile repo-local
309
+ # +core.fsmonitor+ regardless of what the repo says.
310
+ #
311
+ # Via *environment*, not by rewriting the model's command, because the
312
+ # command is opaque shell syntax we refuse to parse, and env applies
313
+ # uniformly to *every* git in the command — each link of a +&&+/+|+ chain,
314
+ # and any git a script underneath forks — with no parsing.
315
+ #
316
+ # Indices are offset past any +GIT_CONFIG_COUNT+ already inherited, so our
317
+ # keys append rather than clobber a host's; the higher index also wins
318
+ # under git's last-wins rule on a duplicate key.
319
+ #
320
+ # @return [Hash{String=>String}]
321
+ def self.git_hardening_delta
322
+ base = ENV['GIT_CONFIG_COUNT'].to_i
323
+ delta = {}
324
+ GIT_HARDENING.each_with_index do |(key, value), i|
325
+ delta["GIT_CONFIG_KEY_#{base + i}"] = key
326
+ delta["GIT_CONFIG_VALUE_#{base + i}"] = value
327
+ end
328
+ delta['GIT_CONFIG_COUNT'] = (base + GIT_HARDENING.size).to_s
329
+ delta
242
330
  end
243
- private_class_method :compose_prompt
331
+ private_class_method :git_hardening_delta
244
332
 
245
- # Escape control bytes for safe display while preserving +\n+
246
- # (multi-line shell commands are normal). Catches +\r+, +\x1b+
247
- # (ESC, the ANSI introducer), +\b+, NUL, DEL, etc. without this,
248
- # a model could craft +command: "\rrm -rf ~/"+ that visually
249
- # overwrites the echo line after the user has already read it.
333
+ # Neutralize control bytes for the *observation* echo (+"$ ..."+ in the
334
+ # tool result) without this a model could craft +command: "\rrm -rf
335
+ # ~/"+ that visually overwrites the echo line after the user read it.
336
+ # Delegates to {Pikuri::Sanitizer} (preserves +\n+, visualizes the rest).
337
+ # The echo is passive, so {Pikuri::Sanitizer::Warning}s are dropped here
338
+ # they surface at the confirmation prompt, before the user approves.
250
339
  #
251
340
  # @param command [String]
252
341
  # @return [String]
253
342
  def self.visible(command)
254
- command.gsub(/[\x00-\x09\x0b-\x1f\x7f]/) { |c| format('\\x%02x', c.ord) }
343
+ Pikuri::Sanitizer.sanitize(command).text
255
344
  end
256
345
  private_class_method :visible
257
346
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Code
5
+ # The +enter_plan_mode+ tool: turns on the shared
6
+ # {Pikuri::Workspace::ReadOnly} flag. Once active, Write/Edit refuse and the
7
+ # {Extension}'s per-turn reminder steers the model to research and propose;
8
+ # the model exits via {ExitPlanMode}, or the host flips the same flag from a
9
+ # key binding / slash command. Entry is just as often host-initiated as
10
+ # model-driven — either way the flag is the single source of truth. (It
11
+ # gates Write/Edit, not Bash — see {Pikuri::Workspace::ReadOnly}.)
12
+ #
13
+ # Sharing: +P_shared_locked+ — the flag it flips is thread-safe, so nothing
14
+ # breaks. Decide the *semantics* on purpose, though: agents sharing one flag
15
+ # share one plan mode, so this agent entering it disables writes for all of
16
+ # them. Per-agent plan mode means a {Pikuri::Workspace::ReadOnly} each.
17
+ class EnterPlanMode < Pikuri::Tool
18
+ # @return [String]
19
+ DESCRIPTION = <<~DESC
20
+ Enter plan mode: a read-only posture for researching and designing a change before writing any of it.
21
+
22
+ Usage:
23
+ - Reach for this before a non-trivial change — new features, multi-file edits, refactors, or anything with several valid approaches — so the user can sign off on the approach before code is written.
24
+ - Skip it for small, unambiguous edits (a typo, an obvious one-line fix) and for pure research questions.
25
+ - While in plan mode the file-editing tools are disabled; explore and reason freely, then present a plan with exit_plan_mode for approval.
26
+ - When unsure, prefer entering: alignment up front is cheaper than redone work.
27
+ DESC
28
+
29
+ # @param read_only [Pikuri::Workspace::ReadOnly] the shared
30
+ # read-only flag to activate.
31
+ # @return [EnterPlanMode]
32
+ def initialize(read_only:)
33
+ super(
34
+ name: 'enter_plan_mode',
35
+ description: DESCRIPTION,
36
+ parameters: Parameters::EMPTY,
37
+ execute: -> { EnterPlanMode.run(read_only: read_only) },
38
+ # No legs: flips a local mode flag.
39
+ trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
40
+ )
41
+ end
42
+
43
+ # Activate plan mode (idempotent) and return the observation that
44
+ # confirms the posture to the model.
45
+ #
46
+ # @param read_only [Pikuri::Workspace::ReadOnly]
47
+ # @return [String] tool observation
48
+ def self.run(read_only:)
49
+ read_only.activate!
50
+ 'You are now in plan mode (read-only): research and design only, do not edit ' \
51
+ 'files. When the plan is ready, call exit_plan_mode to present it for approval.'
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Code
5
+ # The +exit_plan_mode+ tool: presents the finished plan for approval and, on
6
+ # a yes, turns the shared {Pikuri::Workspace::ReadOnly} flag back off. The
7
+ # plan is shown and the yes/no collected through the same
8
+ # {Pikuri::Workspace::Confirmer} the +write+/+bash+ tools use, so
9
+ # presentation + attacker-text sanitization live in one place. On approval
10
+ # the flag flips off and the model is told to proceed; on a decline it stays
11
+ # on and the observation steers a refine-and-re-present, never an edit. The
12
+ # LLM-authored plan is passed as the request +detail+, so the Confirmer
13
+ # neutralizes it before display.
14
+ #
15
+ # Sharing: as {EnterPlanMode} — +P_shared_locked+ on the flag, and sharing
16
+ # it makes plan mode VM-wide. The {Pikuri::Workspace::Confirmer} pulls the
17
+ # other way ({Confirmer::Terminal} is +P_one_agent+), so a shared instance
18
+ # is only as shareable as the confirmer behind it.
19
+ class ExitPlanMode < Pikuri::Tool
20
+ # @return [String]
21
+ DESCRIPTION = <<~DESC
22
+ Exit plan mode by presenting your finished implementation plan for the user's approval.
23
+
24
+ Usage:
25
+ - Call this only once your plan is complete and unambiguous; pass the full plan as the `plan` argument.
26
+ - The user is shown the plan and asked to approve. On approval you leave plan mode and may implement it; on a decline you stay in plan mode — refine the plan and call this again.
27
+ - If a requirement is still unclear, ask the user directly first (without leaving plan mode) rather than presenting a half-formed plan.
28
+ - Do not use this for pure research tasks — those never enter plan mode in the first place.
29
+ DESC
30
+
31
+ # @param read_only [Pikuri::Workspace::ReadOnly] the shared
32
+ # read-only flag to deactivate on approval.
33
+ # @param confirmer [Pikuri::Workspace::Confirmer] consulted to
34
+ # present the plan and collect the approval.
35
+ # @return [ExitPlanMode]
36
+ def initialize(read_only:, confirmer:)
37
+ super(
38
+ name: 'exit_plan_mode',
39
+ description: DESCRIPTION,
40
+ parameters: Parameters.build { |p|
41
+ p.required_string :plan,
42
+ 'Your complete implementation plan, in Markdown, ' \
43
+ 'shown to the user for approval. Cover the files ' \
44
+ 'to change and the changes to each, e.g. ' \
45
+ '"## Plan\n1. Add X to foo.rb\n2. ...".'
46
+ },
47
+ execute: ->(plan:) {
48
+ ExitPlanMode.run(read_only: read_only, confirmer: confirmer, plan: plan)
49
+ },
50
+ # No legs: flips a local mode flag (behind a confirmer, which grades
51
+ # nothing here — there is no leg to gate).
52
+ trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
53
+ )
54
+ end
55
+
56
+ # Present +plan+ for approval; on yes deactivate plan mode, on no
57
+ # keep it active. Returns the observation the model reacts to.
58
+ #
59
+ # @param read_only [Pikuri::Workspace::ReadOnly]
60
+ # @param confirmer [Pikuri::Workspace::Confirmer]
61
+ # @param plan [String] the LLM-authored plan to show the user
62
+ # @return [String] tool observation
63
+ def self.run(read_only:, confirmer:, plan:)
64
+ unless read_only.active?
65
+ return 'Error: not in plan mode, so there is nothing to exit. ' \
66
+ 'Proceed with the work directly.'
67
+ end
68
+
69
+ request = Pikuri::Workspace::Confirmer::Request.new(
70
+ question: 'Approve this plan? (approving exits plan mode and lets the agent edit files)',
71
+ detail: plan
72
+ )
73
+
74
+ if confirmer.confirm?(request: request)
75
+ read_only.deactivate!
76
+ 'Plan approved — you are no longer in plan mode. Implement the plan now.'
77
+ else
78
+ 'The user did not approve the plan; you are still in plan mode (read-only). ' \
79
+ 'Ask what they would like changed, revise the plan, and call exit_plan_mode ' \
80
+ 'again when ready. Do not edit any files.'
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end