pikuri-code 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,91 +3,71 @@
3
3
  module Pikuri
4
4
  module Code
5
5
  # The +bash+ tool — run an arbitrary shell command in the workspace.
6
- # Instantiating +Code::Bash.new(workspace: ws, confirmer: c)+ produces
7
- # a tool whose {Pikuri::Tool#to_ruby_llm_tool} wiring is identical to
8
- # any bundled tool's. Same shape as {Pikuri::Workspace::Write}
9
- # (workspace + confirmer captured by the +execute+ closure at
10
- # 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).
11
9
  #
12
10
  # == Confirmation
13
11
  #
14
- # Every command requires confirmation. Bash composes a semantic
15
- # {Pikuri::Workspace::Confirmer::Request} — question (header line)
16
- # plus detail (+$ <command>+ echo) — and hands it to the confirmer,
17
- # which owns ALL presentation: color, the answer cue, answer
18
- # parsing, and medium-appropriate escaping of the raw bytes (a
19
- # terminal neutralizes control bytes, a web client HTML-escapes).
20
- # The request deliberately carries the command verbatim so each
21
- # renderer can escape for its own medium. The *observation* echo
22
- # (+$ ...+ in the tool result) stays on this side of the seam and
23
- # passes through {.visible}, so the model can't smuggle a
24
- # +\r\033[2K rm -rf ~/+ behind the displayed text there either.
25
- # Execution uses the raw command; only displays are 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.
26
21
  #
27
22
  # == Subprocess wiring
28
23
  #
29
- # The command runs through {Pikuri::Subprocess.spawn} with argv:
30
- #
31
24
  # timeout --signal=TERM --kill-after=5s <timeout>s bash -c <command>
32
25
  #
33
- # +bash -c+ (no +-l+) — no profile/rc sourcing, no inherited login
34
- # environment beyond what pikuri itself runs in. +timeout(1)+ from
35
- # GNU coreutils handles the SIGTERM-then-SIGKILL race; we never use
36
- # Ruby's +Timeout.timeout+ (can't reliably kill subprocesses). The
37
- # +--kill-after=5s+ gives the command 5 seconds to handle SIGTERM
38
- # cleanly before SIGKILL is sent.
39
- #
40
- # The subprocess environment is "de-bundlerized" first — pikuri is
41
- # usually launched under Bundler, which would otherwise leak its own
42
- # +BUNDLE_GEMFILE+ / +RUBYOPT+ / ... into the command so a +bundle
43
- # exec+ run against another project picks up *pikuri's* Gemfile. See
44
- # {.subprocess_env} / {.bundler_clean_delta}.
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}.
45
32
  #
46
33
  # == Timeout detection
47
34
  #
48
- # GNU coreutils' +timeout+ exits +124+ after a successful SIGTERM,
49
- # +137+ after escalating to SIGKILL. We treat both as "command timed
50
- # out". A third code, +125+, is also accepted: uutils-coreutils 0.2.2
51
- # (the Rust reimplementation shipped on some distros) sends the
52
- # SIGTERM correctly but then mis-reports +125+ ("timeout itself
53
- # failed") instead of +124+ whenever +--kill-after+ is in play. False-
54
- # positive risk on real GNU coreutils is low — our argv is fixed and
55
- # well-formed, so a +125+ there would indicate a misconfigured PATH
56
- # rather than a routine timeout.
57
- #
58
- # Caveat: +137+ is ambiguous — a command killed by the OOM-killer
59
- # also exits +137+. v1 accepts the mis-classification; the
60
- # 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).
61
43
  #
62
44
  # == Output handling
63
45
  #
64
- # Combined stdout+stderr (popen2e). Head+tail truncation at
65
- # {OUTPUT_HEAD} + {OUTPUT_TAIL} bytes with a marker reporting both
66
- # bytes-omitted and the original total the model needs the scale
67
- # 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+.
68
49
  #
69
50
  # == Backgrounded subprocesses
70
51
  #
71
- # Plain +cmd &+ does NOT detach — the backgrounded child inherits our
72
- # combined-output pipe by default, so {Pikuri::Subprocess#wait} blocks
73
- # on +io.read+ until the backgrounded process exits. The model must
74
- # redirect fds away from our pipe to genuinely background:
75
- # +cmd >/dev/null 2>&1 &+. Such commands stay in our pgroup and get
76
- # SIGTERM on pikuri exit via {Pikuri::Subprocess.cleanup!}; +nohup+ /
77
- # +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.
78
57
  #
79
- # == Refusals
58
+ # == Sharing
80
59
  #
81
- # 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.
82
66
  #
83
- # * Empty / whitespace-only +command+ fast reject before confirming.
84
- # * +timeout+ outside +[1, MAX_TIMEOUT]+ bounds error.
85
- # * User declined the confirmation → +"Error: user declined ..."+.
86
- # * +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.
87
70
  class Bash < Pikuri::Tool
88
- # Pikuri-convention per-module logger; the +Bash+ progname tags the
89
- # construction-time warning below so it's clear in the shared
90
- # +Pikuri.log_io+ stream which tool issued it.
91
71
  # @return [Logger]
92
72
  LOGGER = Pikuri.logger_for('Bash')
93
73
 
@@ -101,6 +81,29 @@ module Pikuri
101
81
  # passed to +timeout --kill-after=...+.
102
82
  KILL_AFTER = '5s'
103
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
+
104
107
  # @return [Integer] bytes preserved from the start of the output
105
108
  # when the combined-output stream exceeds {OUTPUT_HEAD} + {OUTPUT_TAIL}.
106
109
  OUTPUT_HEAD = 15 * 1024
@@ -112,52 +115,53 @@ module Pikuri
112
115
  # bullets. Per-parameter constraints (default, max) live in the
113
116
  # parameter descriptions.
114
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
+ #
115
124
  # @return [String]
116
125
  DESCRIPTION = <<~DESC
117
126
  Run a bash command in the workspace.
118
127
 
119
128
  Usage:
120
129
  - Use for tasks the dedicated tools can't do: git, tests, package managers, multi-step shell pipelines.
121
- - 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.
122
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.
123
132
  - stdin is closed; interactive commands hang until timeout. Use non-interactive flags (`apt -y`, `git commit -m`).
124
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.
125
134
  - Combined stdout+stderr is returned. Suppress either via `2>/dev/null` etc.
126
135
  - Large outputs are head+tail-truncated. Pipe through `head`/`tail`/`grep`/`wc` to control volume.
127
- - Non-zero exit status is a normal observation, not an error.
128
- - 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.
129
140
  DESC
130
141
 
131
- # @param workspace [Pikuri::Workspace::Filesystem] captured for
132
- # +chdir+; commands run in +workspace.project_root+, always.
133
- # Even when the surrounding Ruby process has already chdir'd
134
- # to the project root (e.g. +bin/pikuri-code+ does this at
135
- # startup), Bash still passes the explicit +chdir:+ so a
136
- # sub-agent, future host, or anyone embedding Bash directly
137
- # gets the same predictable cwd. Bash does NOT path-resolve
138
- # individual arguments the +command+ string is opaque shell
139
- # syntax.
140
- # @param confirmer [Pikuri::Workspace::Confirmer] consulted before
141
- # every command.
142
- # @param sandbox [Code::Bash::Sandbox] filesystem-sandbox seam
143
- # (default {Sandbox::NONE} identity passthrough). Pass
144
- # {Sandbox::Bubblewrap.new(workspace: workspace)+} for an
145
- # isolated subprocess whose filesystem view is the workspace's
146
- # readable/writable roots plus the OS-runtime baseline. See
147
- # {Sandbox} for the rationale.
148
- # @raise [RuntimeError] if +bash+ or +timeout+ aren't on +PATH+;
149
- # 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+.
150
158
  # @return [Bash]
151
- def initialize(workspace:, confirmer:, sandbox: Sandbox::NONE)
159
+ def initialize(filesystem:, confirmer:, sandbox: Sandbox::NONE, passive_detector: nil)
152
160
  Bash.send(:check_binaries!)
153
- # Capability advisory when no sandbox is in play. Without a
154
- # sandbox, +bash+ runs with pikuri's own UID + filesystem view —
155
- # anything readable to the user is readable to the LLM via +cat
156
- # ~/.ssh/id_*+ / +aws configure list+ / etc. The per-command
157
- # +Confirmer+ is the only line of defense in that mode. The
158
- # bundled {Sandbox::Bubblewrap} addresses this concern with a
159
- # filesystem-restricted subprocess; warn only when the host has
160
- # 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.
161
165
  if sandbox.equal?(Sandbox::NONE)
162
166
  LOGGER.warn(
163
167
  'Code::Bash is unsandboxed: commands run under your UID and can read ' \
@@ -180,9 +184,31 @@ module Pikuri
180
184
  "max #{MAX_TIMEOUT}, e.g. 300."
181
185
  },
182
186
  execute: ->(command:, description: nil, timeout: DEFAULT_TIMEOUT) {
183
- Bash.run(workspace: workspace, confirmer: confirmer, sandbox: sandbox,
187
+ Bash.run(filesystem: filesystem, confirmer: confirmer, sandbox: sandbox,
188
+ passive_detector: passive_detector,
184
189
  command: command, description: description, timeout: timeout)
185
- }
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
+ )
186
212
  )
187
213
  end
188
214
 
@@ -190,29 +216,42 @@ module Pikuri
190
216
  # either +"$ ...\n<out>\n\nexit status: N"+ on a normal exit, or
191
217
  # +"Error: ..."+ on rejection / timeout / bad inputs.
192
218
  #
193
- # @param workspace [Pikuri::Workspace::Filesystem]
219
+ # @param filesystem [Pikuri::Workspace::Filesystem]
194
220
  # @param confirmer [Pikuri::Workspace::Confirmer]
195
- # @param sandbox [Code::Bash::Sandbox] wraps the spawned argv;
196
- # {Sandbox::NONE} for identity passthrough,
197
- # {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}.
198
224
  # @param command [String] raw command as supplied by the LLM
199
225
  # @param description [String, nil] optional short label for the user
200
226
  # @param timeout [Integer] seconds before SIGTERM is sent
201
227
  # @return [String]
202
- 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)
203
230
  return 'Error: empty bash command.' if command.strip.empty?
204
231
  return "Error: timeout must be >= 1, got #{timeout}" if timeout < 1
205
232
  return "Error: timeout must be <= #{MAX_TIMEOUT}, got #{timeout}" if timeout > MAX_TIMEOUT
206
233
 
207
- request = compose_request(command: command, description: description, timeout: timeout)
208
- return 'Error: user declined the bash command.' unless confirmer.confirm?(request: request)
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
209
248
 
210
249
  argv = sandbox.wrap([
211
250
  'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s",
212
251
  'bash', '-c', command
213
252
  ])
214
- result = Pikuri::Subprocess.spawn(*argv, chdir: workspace.project_root.to_s,
215
- env: subprocess_env(workspace)).wait
253
+ result = Pikuri::Subprocess.spawn(*argv, chdir: filesystem.project_root.to_s,
254
+ env: subprocess_env(filesystem)).wait
216
255
 
217
256
  output = truncate(result.output)
218
257
  exit_code = result.status.exitstatus
@@ -249,90 +288,54 @@ module Pikuri
249
288
  end
250
289
  private_class_method :compose_request
251
290
 
252
- # Environment for the bash subprocess: the workspace's git-identity
253
- # vars ({Pikuri::Workspace::Filesystem#env}) layered over a
254
- # "de-bundlerized" base.
255
- #
256
- # pikuri is typically launched under Bundler the +bin/pikuri-code+
257
- # demo and every downstream host (e.g. pikuri-tui) pin
258
- # +BUNDLE_GEMFILE+ to their *own* Gemfile and +require
259
- # 'bundler/setup'+ at boot. Doing so makes Bundler rewrite
260
- # +BUNDLE_GEMFILE+ / +RUBYOPT+ / +GEM_HOME+ / +GEM_PATH+ / +RUBYLIB+
261
- # / +PATH+ in the *process* environment (stashing the pre-bundler
262
- # values under +BUNDLER_ORIG_*+). Those vars are inherited by every
263
- # subprocess we spawn — so a +bundle exec rspec+ the agent runs in
264
- # *another* Ruby project would resolve against PIKURI's Gemfile and
265
- # die with a baffling "the Gemfile specifies ... is missing" error.
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.
266
297
  #
267
- # The fix is to hand bash the environment as it was *before* bundler
268
- # touched it (see {.bundler_clean_delta}), with the workspace's git
269
- # identity on top.
270
- #
271
- # @param workspace [Pikuri::Workspace::Filesystem]
298
+ # @param filesystem [Pikuri::Workspace::Filesystem]
272
299
  # @return [Hash{String=>(String,nil)}] +env:+ delta for
273
- # {Pikuri::Subprocess.spawn} (a +nil+ value unsets the key in the
274
- # child).
275
- def self.subprocess_env(workspace)
276
- bundler_clean_delta.merge(workspace.env)
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)
277
303
  end
278
304
  private_class_method :subprocess_env
279
305
 
280
- # The delta that, merged onto the current (bundler-polluted) +ENV+
281
- # via {Pikuri::Subprocess.spawn}'s +env:+, reconstructs
282
- # +Bundler.unbundled_env+ the environment as it was before
283
- # +bundler/setup+ ran.
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.
284
310
  #
285
- # +Bundler.unbundled_env+ restores each +BUNDLER_ORIG_*+ backup, so
286
- # a host-level +GEM_HOME+/+GEM_PATH+ from a +sudo apt install ruby+
287
- # install (pointing at +~/.gem+) passes straight through and the
288
- # subprocess still finds the user's gemsit is *not* a blanket
289
- # +GEM_*+ wipe — while the bundler-only vars (+BUNDLE_*+, the
290
- # +-rbundler/setup+ +RUBYOPT+) are dropped. We diff against the live
291
- # +ENV+ rather than passing the full hash because +spawn+'s +env:+
292
- # is additive: changed/added keys carry their clean value, keys
293
- # bundler injected that aren't in the clean env map to +nil+
294
- # (+Open3.popen2e+ unsets a +nil+-valued key in the child).
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.
295
315
  #
296
- # When pikuri runs *without* Bundler (gem-installed, or a host that
297
- # never required +bundler/setup+) this is empty and the child
298
- # inherits the environment unchanged.
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.
299
319
  #
300
- # @return [Hash{String=>(String,nil)}]
301
- def self.bundler_clean_delta
302
- return {} unless defined?(Bundler) && Bundler.respond_to?(:unbundled_env)
303
-
304
- env_delta(current: ENV.to_h, clean: Bundler.unbundled_env)
305
- end
306
- private_class_method :bundler_clean_delta
307
-
308
- # Pure diff between two environment hashes: the additive delta that,
309
- # applied to +current+ via {Pikuri::Subprocess.spawn}'s +env:+,
310
- # yields +clean+. A key whose value differs (or is new in +clean+)
311
- # carries the +clean+ value; a key present in +current+ but absent
312
- # from +clean+ maps to +nil+ (which +Open3.popen2e+ unsets). Keys
313
- # equal in both are omitted, keeping the delta minimal.
314
- #
315
- # @param current [Hash{String=>String}]
316
- # @param clean [Hash{String=>String}]
317
- # @return [Hash{String=>(String,nil)}]
318
- def self.env_delta(current:, clean:)
320
+ # @return [Hash{String=>String}]
321
+ def self.git_hardening_delta
322
+ base = ENV['GIT_CONFIG_COUNT'].to_i
319
323
  delta = {}
320
- clean.each { |k, v| delta[k] = v unless current[k] == v }
321
- current.each_key { |k| delta[k] = nil unless clean.key?(k) }
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
322
329
  delta
323
330
  end
324
- private_class_method :env_delta
331
+ private_class_method :git_hardening_delta
325
332
 
326
- # Neutralize control bytes for safe display in the *observation*
327
- # echo (+"$ ..."+ in the tool result) — without this, a model could
328
- # craft +command: "\rrm -rf ~/"+ that visually overwrites the echo
329
- # line after the user has already read it. Delegates to the shared
330
- # {Pikuri::Sanitizer}, which preserves +\n+ (multi-line shell
331
- # commands are normal) and visualizes the rest; the confirmation
332
- # prompt routes the same command through the same primitive (see
333
- # +Confirmer::Terminal+). The echo is passive, so the
334
- # {Pikuri::Sanitizer::Warning}s are dropped here — they surface at
335
- # the confirmation prompt, before the user approves.
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.
336
339
  #
337
340
  # @param command [String]
338
341
  # @return [String]
@@ -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