pikuri-core 0.0.7 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/pikuri/agent/chat_transport.rb +73 -93
- data/lib/pikuri/agent/configurator.rb +46 -106
- data/lib/pikuri/agent/context_window_detector.rb +44 -85
- data/lib/pikuri/agent/control/cancellable.rb +87 -66
- data/lib/pikuri/agent/control/interloper.rb +127 -105
- data/lib/pikuri/agent/control/step_limit.rb +25 -41
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +123 -188
- data/lib/pikuri/agent/extension.rb +118 -94
- data/lib/pikuri/agent/extension_context.rb +50 -77
- data/lib/pikuri/agent/history.rb +653 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
- data/lib/pikuri/agent/listener/terminal.rb +143 -117
- data/lib/pikuri/agent/listener/token_log.rb +101 -140
- data/lib/pikuri/agent/listener.rb +23 -43
- data/lib/pikuri/agent/listener_list.rb +26 -47
- data/lib/pikuri/agent/synthesizer.rb +45 -87
- data/lib/pikuri/agent.rb +816 -474
- data/lib/pikuri/bundler_env.rb +68 -0
- data/lib/pikuri/extractor/html.rb +63 -110
- data/lib/pikuri/extractor/passthrough.rb +20 -30
- data/lib/pikuri/extractor.rb +93 -154
- data/lib/pikuri/file_type.rb +63 -135
- data/lib/pikuri/finalizers.rb +32 -47
- data/lib/pikuri/paths.rb +104 -13
- data/lib/pikuri/ruby_llm_patches.rb +106 -0
- data/lib/pikuri/sanitizer.rb +45 -67
- data/lib/pikuri/subprocess.rb +75 -119
- data/lib/pikuri/testing.rb +296 -0
- data/lib/pikuri/tool/calculator.rb +56 -66
- data/lib/pikuri/tool/execute_context.rb +42 -0
- data/lib/pikuri/tool/fetch.rb +51 -77
- data/lib/pikuri/tool/parameters.rb +21 -29
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +52 -80
- data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
- data/lib/pikuri/tool/search/engines.rb +230 -97
- data/lib/pikuri/tool/search/exa.rb +56 -90
- data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
- data/lib/pikuri/tool/search/result.rb +10 -15
- data/lib/pikuri/tool/trifecta_legs.rb +217 -0
- data/lib/pikuri/tool/web_scrape.rb +38 -54
- data/lib/pikuri/tool/web_search.rb +100 -24
- data/lib/pikuri/tool.rb +140 -65
- data/lib/pikuri/trifecta/contribution.rb +43 -0
- data/lib/pikuri/trifecta/node.rb +47 -0
- data/lib/pikuri/trifecta/report.rb +230 -0
- data/lib/pikuri/trifecta.rb +127 -0
- data/lib/pikuri/url_cache.rb +33 -49
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +72 -88
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- metadata +14 -3
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Trifecta
|
|
5
|
+
# The result of walking one agent tree: a {Node} with its legs folded — its
|
|
6
|
+
# own tools unioned with everything propagated up from its sub-agents. What
|
|
7
|
+
# {Pikuri::Trifecta.walk} returns, mirroring the tree shape so a host can
|
|
8
|
+
# both classify and display it:
|
|
9
|
+
#
|
|
10
|
+
# report = Pikuri::Trifecta.walk(node)
|
|
11
|
+
# report.tree_verdict # => :soft
|
|
12
|
+
# report.worst_path.map(&:label) # => ["pikuri-computer", "RESEARCHER"]
|
|
13
|
+
# puts report.render # the human-readable map
|
|
14
|
+
#
|
|
15
|
+
# == Implementation details
|
|
16
|
+
#
|
|
17
|
+
# {#verdict} is *derived, never stored*: +Data#with+ would otherwise let a
|
|
18
|
+
# copy carry a severity that contradicts its own legs. It reads presence
|
|
19
|
+
# (leg-absence, correct for any level ever added) and matches strength
|
|
20
|
+
# exhaustively across all three graded axes, so introducing a lattice level
|
|
21
|
+
# without placing it in the grid raises on the first wiring that reaches it
|
|
22
|
+
# rather than being silently absorbed into +:soft+.
|
|
23
|
+
#
|
|
24
|
+
# Immutable.
|
|
25
|
+
#
|
|
26
|
+
# @!attribute [r] label
|
|
27
|
+
# @return [String] this agent's name (see {Node#label})
|
|
28
|
+
# @!attribute [r] legs
|
|
29
|
+
# @return [Pikuri::Tool::TrifectaLegs] folded legs — own tools plus
|
|
30
|
+
# everything propagated up from {#children}. What {#verdict} reads.
|
|
31
|
+
# @!attribute [r] tool_legs
|
|
32
|
+
# @return [Hash{String=>Pikuri::Tool::TrifectaLegs}] carried through from
|
|
33
|
+
# the {Node} so {#render} can print the tools as leaves
|
|
34
|
+
# @!attribute [r] children
|
|
35
|
+
# @return [Array<Report>] one per sub-agent, already walked
|
|
36
|
+
# @!attribute [r] channel_egress
|
|
37
|
+
# @return [Symbol, nil] the parent edge's gate level (see
|
|
38
|
+
# {Node#channel_egress})
|
|
39
|
+
class Report < Data.define(:label, :legs, :tool_legs, :children, :channel_egress)
|
|
40
|
+
# Verdicts, least to most severe. The only ordering outside
|
|
41
|
+
# {Pikuri::Tool::TrifectaLegs}.
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<Symbol>]
|
|
44
|
+
SEVERITY = %i[silent soft loud].freeze
|
|
45
|
+
|
|
46
|
+
# Closing pointer of {#advisory}. Names the chapter by *slug*, not by
|
|
47
|
+
# ordinal: book chapters are ordered only by `book/README.md`, so this
|
|
48
|
+
# path survives a reordering that would silently falsify a number.
|
|
49
|
+
#
|
|
50
|
+
# @return [String]
|
|
51
|
+
FOOTER = 'See book/trifecta-detector.md in this repo.'
|
|
52
|
+
|
|
53
|
+
# How dangerous *this node* is — the tree's verdict is {#tree_verdict}.
|
|
54
|
+
#
|
|
55
|
+
# +:loud+ needs all three legs at full strength; +:soft+ is any complete
|
|
56
|
+
# trifecta whose untrusted or egress leg is attenuated; +:silent+ is a
|
|
57
|
+
# missing leg. Absence of a warning is not evidence of safety: this
|
|
58
|
+
# reasons about capability presence, not data flow.
|
|
59
|
+
#
|
|
60
|
+
# @return [Symbol] +:silent+, +:soft+ or +:loud+
|
|
61
|
+
# @raise [NoMatchingPatternError] if a lattice level exists that this grid
|
|
62
|
+
# does not place — deliberate, so a new level cannot be absorbed silently
|
|
63
|
+
def verdict
|
|
64
|
+
return :silent unless legs.private && legs.untrusted != :none && legs.egress?
|
|
65
|
+
|
|
66
|
+
case [legs.untrusted, legs.egress_payload_review, legs.egress_destination]
|
|
67
|
+
in [:hard, :unreviewed, :attacker_reachable] then :loud
|
|
68
|
+
in [:hard, :unreviewed, :tool_vouched] then :soft
|
|
69
|
+
in [:hard, :human_reviewed, :attacker_reachable] then :soft
|
|
70
|
+
in [:hard, :human_reviewed, :tool_vouched] then :soft
|
|
71
|
+
in [:weakened, :unreviewed, :attacker_reachable] then :soft
|
|
72
|
+
in [:weakened, :unreviewed, :tool_vouched] then :soft
|
|
73
|
+
in [:weakened, :human_reviewed, :attacker_reachable] then :soft
|
|
74
|
+
in [:weakened, :human_reviewed, :tool_vouched] then :soft
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Root-first path of Reports down to the most severe node in this subtree,
|
|
79
|
+
# so the caller can both attribute the finding and read its cell:
|
|
80
|
+
#
|
|
81
|
+
# path = report.worst_path
|
|
82
|
+
# path.map(&:label).join(' -> ') # => "pikuri-computer -> RESEARCHER"
|
|
83
|
+
# path.last.legs # the cell that decided the verdict
|
|
84
|
+
#
|
|
85
|
+
# Ties go to the node encountered first in pre-order — the parent over its
|
|
86
|
+
# own children, since a parent's trifecta is the more actionable finding.
|
|
87
|
+
#
|
|
88
|
+
# @return [Array<Report>] never empty; +[self]+ when nothing below is worse
|
|
89
|
+
def worst_path
|
|
90
|
+
best = [self]
|
|
91
|
+
children.each do |child|
|
|
92
|
+
candidate = [self, *child.worst_path]
|
|
93
|
+
best = candidate if SEVERITY.index(candidate.last.verdict) > SEVERITY.index(best.last.verdict)
|
|
94
|
+
end
|
|
95
|
+
best
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Symbol] the most severe {#verdict} anywhere in this subtree
|
|
99
|
+
def tree_verdict = worst_path.last.verdict
|
|
100
|
+
|
|
101
|
+
# The human-readable map: one line per agent and per tool, indented by
|
|
102
|
+
# depth, with each entry's legs as tags.
|
|
103
|
+
#
|
|
104
|
+
# Trifecta: soft
|
|
105
|
+
#
|
|
106
|
+
# pikuri-computer [:private, :untrusted_hard, :egress_reviewed]
|
|
107
|
+
# mail_compose [:private, :egress_reviewed]
|
|
108
|
+
# calculator []
|
|
109
|
+
# RESEARCHER [gate: human_reviewed] [:untrusted_hard, :egress]
|
|
110
|
+
# fetch [:untrusted_hard, :egress]
|
|
111
|
+
#
|
|
112
|
+
# See book/trifecta-detector.md in this repo for ...
|
|
113
|
+
#
|
|
114
|
+
# An agent line carries its *folded* legs and a tool line its own, so
|
|
115
|
+
# propagation shows up as layout: the parent above holds egress that no
|
|
116
|
+
# tool of its own contributes. Plain text by design — a host wanting color
|
|
117
|
+
# or width walks this tree itself rather than asking for styled output.
|
|
118
|
+
#
|
|
119
|
+
# @return [String] newline-separated, no trailing newline
|
|
120
|
+
def render
|
|
121
|
+
[advisory, '', *render_lines].join("\n")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The one-line conclusion: what was found, where, and the leg to break.
|
|
125
|
+
# Separate from {#render} because a status line wants this without the
|
|
126
|
+
# tree, and because it is the half that carries *meaning* — the map
|
|
127
|
+
# deliberately carries only facts.
|
|
128
|
+
#
|
|
129
|
+
# [TRIFECTA: HARD] pikuri-code — private(read, bash) + untrusted(read,
|
|
130
|
+
# bash) + egress(bash). An injection in what this agent reads can drive
|
|
131
|
+
# an exfiltration. Break a leg: sever egress (a network-off sandbox), or
|
|
132
|
+
# privilege-separate the step that reads untrusted content.
|
|
133
|
+
# See book/trifecta-detector.md in this repo.
|
|
134
|
+
#
|
|
135
|
+
# A clean verdict never says "no trifecta detected". This reasons about
|
|
136
|
+
# capability presence, so a quiet result is not a clean bill of health —
|
|
137
|
+
# it under-warns on a single tool that is all three internally, and on
|
|
138
|
+
# private data staged into a file by one tool and read by another.
|
|
139
|
+
# Reporting the *finding* names what would tip the wiring over and claims
|
|
140
|
+
# nothing it cannot support.
|
|
141
|
+
#
|
|
142
|
+
# @return [String]
|
|
143
|
+
def advisory
|
|
144
|
+
path = worst_path
|
|
145
|
+
where = path.map(&:label).join(' -> ')
|
|
146
|
+
legs = path.last.legs
|
|
147
|
+
return "Trifecta: #{where} — #{presence(legs)}." if tree_verdict == :silent
|
|
148
|
+
|
|
149
|
+
"[TRIFECTA: #{tree_verdict == :loud ? 'HARD' : 'soft'}] #{where} — " \
|
|
150
|
+
"#{attribution(path.last)}. An injection in what this agent reads can drive " \
|
|
151
|
+
"an exfiltration. Break a leg: #{advice(legs)}. #{FOOTER}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
protected
|
|
155
|
+
|
|
156
|
+
# @param depth [Integer] indentation level
|
|
157
|
+
# @return [Array<String>]
|
|
158
|
+
def render_lines(depth = 0)
|
|
159
|
+
indent = ' ' * depth
|
|
160
|
+
gate = channel_egress ? " [gate: #{channel_egress}]" : ''
|
|
161
|
+
lines = ["#{indent}#{label}#{gate} #{legs.to_tags.inspect}"]
|
|
162
|
+
tool_legs.each { |name, tool| lines << "#{indent} #{name} #{tool.to_tags.inspect}" }
|
|
163
|
+
children.each { |child| lines.concat(child.render_lines(depth + 1)) }
|
|
164
|
+
lines
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# @param legs [Pikuri::Tool::TrifectaLegs]
|
|
170
|
+
# @return [String] e.g. +"2 of 3 legs (private, untrusted); no egress leg found"+
|
|
171
|
+
def presence(legs)
|
|
172
|
+
held = { 'private' => legs.private, 'untrusted' => legs.untrusted != :none,
|
|
173
|
+
'egress' => legs.egress? }
|
|
174
|
+
"#{held.count { |_, v| v }} of 3 legs (#{held.select { |_, v| v }.keys.join(', ')}); " \
|
|
175
|
+
"no #{held.reject { |_, v| v }.keys.join('/')} leg found"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Which tool contributed each leg, so the line names something the reader
|
|
179
|
+
# can actually go and change.
|
|
180
|
+
#
|
|
181
|
+
# @param node [Report]
|
|
182
|
+
# @return [String]
|
|
183
|
+
def attribution(node)
|
|
184
|
+
%i[private untrusted egress].filter_map do |axis|
|
|
185
|
+
contributors = node.tool_legs.select { |_, l| leg_present?(l, axis) }.keys
|
|
186
|
+
"#{axis}(#{contributors.join(', ')})" unless contributors.empty?
|
|
187
|
+
end.join(' + ')
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @param legs [Pikuri::Tool::TrifectaLegs]
|
|
191
|
+
# @param axis [Symbol] +:private+, +:untrusted+ or +:egress+
|
|
192
|
+
# @return [Boolean]
|
|
193
|
+
def leg_present?(legs, axis)
|
|
194
|
+
case axis
|
|
195
|
+
in :private then legs.private
|
|
196
|
+
in :untrusted then legs.untrusted != :none
|
|
197
|
+
in :egress then legs.egress?
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The fix, per cell of the verdict grid — the soft cells want different
|
|
202
|
+
# answers, which is why this reads the legs rather than the verdict symbol.
|
|
203
|
+
#
|
|
204
|
+
# @param legs [Pikuri::Tool::TrifectaLegs]
|
|
205
|
+
# @return [String]
|
|
206
|
+
def advice(legs)
|
|
207
|
+
# A vouched destination is fragile in a way the other attenuations are
|
|
208
|
+
# not: it survives only while it is the *sole* egress leg, since the
|
|
209
|
+
# union hardens the whole node the moment one attacker-reachable tool
|
|
210
|
+
# joins it. Say so, rather than let the soft verdict read as settled.
|
|
211
|
+
if legs.egress_destination == :tool_vouched
|
|
212
|
+
return 'sever egress outright — bytes still leave, they merely reach a destination the ' \
|
|
213
|
+
'attacker cannot read back, and one attacker-reachable tool re-hardens this node'
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
case [legs.untrusted, legs.egress_payload_review]
|
|
217
|
+
in [:hard, :unreviewed]
|
|
218
|
+
'sever egress (a network-off sandbox), or privilege-separate the step that reads untrusted content'
|
|
219
|
+
in [:hard, _]
|
|
220
|
+
'privilege-separate the step that reads untrusted content, or declare the workspace trusted ' \
|
|
221
|
+
'if you vouch for every byte it can reach'
|
|
222
|
+
in [:weakened, :unreviewed]
|
|
223
|
+
'sever egress — the untrusted leg is already attenuated by delegation'
|
|
224
|
+
in [:weakened, _]
|
|
225
|
+
'both legs are already attenuated; narrow what counts as private, or drop a leg outright'
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
# The lethal-trifecta detector: does one agent simultaneously hold private
|
|
5
|
+
# data, exposure to attacker-influenceable content, and a way to send bytes
|
|
6
|
+
# out? Any two legs are fine; all three is the live wire, because an injection
|
|
7
|
+
# hidden in the untrusted content can drive the exfiltration.
|
|
8
|
+
#
|
|
9
|
+
# Build a {Node} tree — the agent, its tools, its sub-agents — and walk it:
|
|
10
|
+
#
|
|
11
|
+
# report = Pikuri::Trifecta.walk(node)
|
|
12
|
+
# report.tree_verdict # => :loud
|
|
13
|
+
# puts report.render # the map a bin script prints
|
|
14
|
+
#
|
|
15
|
+
# Nothing here reports itself. Core computes the picture and stops; surfacing
|
|
16
|
+
# it belongs to whoever has a screen ({Pikuri::Agent#trifecta} is the reader).
|
|
17
|
+
# A library writing to a log its embedder never asked for is noise, and a
|
|
18
|
+
# once-per-process guard would be worse than silence in a host that builds
|
|
19
|
+
# two differently-wired agents: it would report the first and stay quiet
|
|
20
|
+
# about the second, which reads as coverage.
|
|
21
|
+
#
|
|
22
|
+
# == This is a smoke alarm, not a firewall
|
|
23
|
+
#
|
|
24
|
+
# It reasons about *capability presence*, never data flow, so it both
|
|
25
|
+
# over-warns (an egress query too narrow to carry anything) and under-warns
|
|
26
|
+
# (one tool that is all three internally; private data staged into a file by
|
|
27
|
+
# one tool and read out by another). It can prove you hold the ingredients. It
|
|
28
|
+
# can prove neither that you are exploitable nor that you are safe — which is
|
|
29
|
+
# why nothing here ever blocks execution, and why the absence of a warning is
|
|
30
|
+
# never reported as an assurance.
|
|
31
|
+
#
|
|
32
|
+
# == Implementation details
|
|
33
|
+
#
|
|
34
|
+
# {.walk} is a pure function of the tree, so the whole rule set is testable
|
|
35
|
+
# from hand-built {Node}s with no agent, no LLM and no sandbox probe.
|
|
36
|
+
#
|
|
37
|
+
# Two operations move legs between nodes, and their asymmetry is the design:
|
|
38
|
+
# +max+ in *parallel* (legs meeting at one node, {Pikuri::Tool::TrifectaLegs#|})
|
|
39
|
+
# and +min+ in *series* (a leg squeezed through a delegation channel,
|
|
40
|
+
# {Pikuri::Tool::TrifectaLegs#cap_payload_review}). Severity is evaluated at every node
|
|
41
|
+
# rather than only at the root, and the tree's verdict is the max over all of
|
|
42
|
+
# them — which localizes a finding to the agent that actually holds it instead
|
|
43
|
+
# of smearing a sub-agent's danger across its parent.
|
|
44
|
+
module Trifecta
|
|
45
|
+
class << self
|
|
46
|
+
# Assemble the tree for a wired agent: its own tools, plus whatever each
|
|
47
|
+
# extension contributes ({Pikuri::Agent::Extension#trifecta_contribution}
|
|
48
|
+
# — legs no tool can declare, and a child per sub-agent persona).
|
|
49
|
+
#
|
|
50
|
+
# @param agent [Pikuri::Agent] a *wired* agent (after the bind sweep, so
|
|
51
|
+
# dynamically-added tools are present)
|
|
52
|
+
# @param label [String] name for the root node; defaults to the running
|
|
53
|
+
# program, which is what a host wants in a boot banner
|
|
54
|
+
# @return [Node]
|
|
55
|
+
def build(agent, label: File.basename($PROGRAM_NAME))
|
|
56
|
+
# The root node holds +tools+ ONLY. +sub_agent_tools+ are registered for
|
|
57
|
+
# personas and never reach the parent's LLM — crediting the parent with
|
|
58
|
+
# them reports the sub-agent quarantine as broken, which is the one
|
|
59
|
+
# false alarm guaranteed to get this advisory muted, since that
|
|
60
|
+
# quarantine is the defense the loudest wirings are built around.
|
|
61
|
+
#
|
|
62
|
+
# The *hook* gets both, because a persona resolves its +tool_names+
|
|
63
|
+
# against either list.
|
|
64
|
+
#
|
|
65
|
+
# A tool neither list places — registered on the sub-agent pool and
|
|
66
|
+
# named by no persona — therefore appears nowhere in the tree, and gets
|
|
67
|
+
# no line of its own. Deliberate: nothing here can call it, so it is the
|
|
68
|
+
# absence of a capability rather than one, and it can never explain a
|
|
69
|
+
# verdict. The reverse direction *is* a bug and fails loudly elsewhere —
|
|
70
|
+
# {Pikuri::SubAgent::Extension#configure} raises on a persona naming an
|
|
71
|
+
# unregistered tool. See +D_trifecta_map_orphans+.
|
|
72
|
+
contributions = agent.extensions.filter_map do |ext|
|
|
73
|
+
if ext.respond_to?(:trifecta_contribution)
|
|
74
|
+
ext.trifecta_contribution(agent.tools + agent.sub_agent_tools)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
tool_legs = agent.tools.to_h { |t| [t.name, t.trifecta_legs] }
|
|
79
|
+
contributions.each do |contribution|
|
|
80
|
+
next if contribution.legs == Tool::TrifectaLegs::NONE
|
|
81
|
+
|
|
82
|
+
previous = tool_legs[contribution.label] || Tool::TrifectaLegs::NONE
|
|
83
|
+
tool_legs[contribution.label] = previous | contribution.legs
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Node.new(label: label, tool_legs: tool_legs,
|
|
87
|
+
children: contributions.flat_map(&:children))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Fold a {Node} tree into a {Report} tree: each node's own tools unioned,
|
|
91
|
+
# then every sub-agent's legs propagated up.
|
|
92
|
+
#
|
|
93
|
+
# @param node [Node] root of the tree, usually the main agent
|
|
94
|
+
# @return [Report] mirrors the input tree, legs folded
|
|
95
|
+
def walk(node)
|
|
96
|
+
children = node.children.map { |child| walk(child) }
|
|
97
|
+
legs = node.tool_legs.values.reduce(Tool::TrifectaLegs::NONE) { |acc, tool| acc | tool }
|
|
98
|
+
legs = children.reduce(legs) { |acc, child| acc | propagated(child) }
|
|
99
|
+
|
|
100
|
+
Report.new(label: node.label, legs: legs, tool_legs: node.tool_legs,
|
|
101
|
+
children: children, channel_egress: node.channel_egress)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# What +child+ contributes to its parent: untrusted attenuated by the
|
|
107
|
+
# boundary, egress capped by the gate on the delegation channel.
|
|
108
|
+
#
|
|
109
|
+
# The cap is skipped when the child holds private data of its own, and
|
|
110
|
+
# that precondition is load-bearing rather than a nicety. A child with
|
|
111
|
+
# file tools can be handed an innocuous approved task, read secrets
|
|
112
|
+
# *after* the gate, and egress a derivative of bytes no human ever saw —
|
|
113
|
+
# so the channel no longer bounds what can leave.
|
|
114
|
+
#
|
|
115
|
+
# @param child [Report]
|
|
116
|
+
# @return [Pikuri::Tool::TrifectaLegs]
|
|
117
|
+
def propagated(child)
|
|
118
|
+
contribution = child.legs.relabel_inbound
|
|
119
|
+
return contribution if child.legs.private
|
|
120
|
+
|
|
121
|
+
# A child with no recorded edge is treated as ungated: absent evidence
|
|
122
|
+
# of a gate is not evidence of one.
|
|
123
|
+
contribution.cap_payload_review(child.channel_egress || :unreviewed)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/pikuri/url_cache.rb
CHANGED
|
@@ -4,52 +4,39 @@ require 'digest'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
|
|
6
6
|
module Pikuri
|
|
7
|
-
# On-disk cache for string-keyed text payloads.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# rendered result list for a query (the query string itself acts as the
|
|
12
|
-
# key — keys are SHA-256 hashed, so any opaque string works).
|
|
7
|
+
# On-disk cache for string-keyed text payloads. The bundled tools use it to
|
|
8
|
+
# avoid re-fetching within a TTL: {Tool::WebScrape.visit} caches a URL's
|
|
9
|
+
# rendered Markdown, {Tool::Search::Engines.search} caches a query's result
|
|
10
|
+
# list (keys are SHA-256 hashed, so any opaque string works).
|
|
13
11
|
#
|
|
14
|
-
# Each tool wires its own
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# is no global default singleton — pass a fresh instance to whichever
|
|
18
|
-
# code needs caching, or use {NULL} to disable caching entirely.
|
|
12
|
+
# Each tool wires its own instance against a dedicated subdir under {ROOT_DIR},
|
|
13
|
+
# so a +web_search+ query and a +web_scrape+ URL can't collide on one file.
|
|
14
|
+
# No global singleton — pass a fresh instance, or {NULL} to disable caching.
|
|
19
15
|
#
|
|
20
|
-
# One file per entry
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# with the same key. To clear the cache, +rm -rf+ the directory.
|
|
16
|
+
# One file per entry (+<sha256>.txt+), freshness by mtime, no sidecar
|
|
17
|
+
# metadata; a stale entry is overwritten on the next {#fetch} with that key.
|
|
18
|
+
# +rm -rf+ the directory to clear it.
|
|
24
19
|
#
|
|
25
|
-
#
|
|
26
|
-
# and
|
|
27
|
-
#
|
|
20
|
+
# Sharing: +P_shared_benign+ — an instance holds only its TTL and directory,
|
|
21
|
+
# and every tool that owns one holds it in a class-level constant, so all
|
|
22
|
+
# agents (and other pikuri processes) hit the same files. If two callers race
|
|
23
|
+
# on the same cold key, both compute and both write the same bytes — the
|
|
24
|
+
# accepted tradeoff for keeping this a few dozen lines (worst case: a
|
|
25
|
+
# duplicate fetch). The cross-agent flow is deliberate: a public URL's content
|
|
26
|
+
# is nobody's private context.
|
|
28
27
|
class UrlCache
|
|
29
|
-
# Root
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# (e.g. +"#{ROOT_DIR}/web_scrape"+) so keys from different tools cannot
|
|
34
|
-
# collide. The directory is created lazily on first cache write; pikuri
|
|
35
|
-
# does not pre-create it.
|
|
28
|
+
# Root dir for per-tool cache subdirs, per XDG:
|
|
29
|
+
# +$XDG_CACHE_HOME/pikuri/url_cache+ or +~/.cache/pikuri/url_cache+. Each
|
|
30
|
+
# tool picks its own subdir (+"#{ROOT_DIR}/web_scrape"+) so keys can't
|
|
31
|
+
# collide. Created lazily on first write.
|
|
36
32
|
# @return [String]
|
|
37
|
-
ROOT_DIR =
|
|
38
|
-
xdg = ENV['XDG_CACHE_HOME']
|
|
39
|
-
cache_home = xdg && !xdg.empty? ? xdg : File.join(Dir.home, '.cache')
|
|
40
|
-
File.join(cache_home, 'pikuri', 'url_cache')
|
|
41
|
-
end.freeze
|
|
33
|
+
ROOT_DIR = Paths.cache.join('url_cache').to_path
|
|
42
34
|
|
|
43
|
-
# Default freshness window: 2 hours
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# next day doesn't serve stale news, docs, or search results.
|
|
49
|
-
# Reference points: opencode keeps no cache, the +pi-web-fetch+
|
|
50
|
-
# community extension uses 15 minutes, +pi-web-search+ uses 5;
|
|
51
|
-
# 2 hours sits comfortably above the "single follow-up" window
|
|
52
|
-
# those numbers are aimed at without holding content across days.
|
|
35
|
+
# Default freshness window: 2 hours. Long enough to cover an interactive
|
|
36
|
+
# session (revisiting a page or re-running a search within the working
|
|
37
|
+
# window hits the cache), short enough that resuming the next day doesn't
|
|
38
|
+
# serve stale results. (opencode caches nothing; +pi-web-fetch+ uses 15 min,
|
|
39
|
+
# +pi-web-search+ 5.)
|
|
53
40
|
# @return [Integer]
|
|
54
41
|
DEFAULT_TTL = 2 * 60 * 60
|
|
55
42
|
|
|
@@ -62,13 +49,11 @@ module Pikuri
|
|
|
62
49
|
@dir = dir
|
|
63
50
|
end
|
|
64
51
|
|
|
65
|
-
# Return the cached payload for +url+ if
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
# The block is only invoked on a miss. If the block raises, no file is
|
|
69
|
-
# written — errors are not cached.
|
|
52
|
+
# Return the cached payload for +url+ if fresh, else yield to compute it,
|
|
53
|
+
# persist, and return. The block runs only on a miss; if it raises, no file
|
|
54
|
+
# is written (errors aren't cached).
|
|
70
55
|
#
|
|
71
|
-
# @param url [String] cache key; a URL or any opaque string
|
|
56
|
+
# @param url [String] cache key; a URL or any opaque string
|
|
72
57
|
# @yieldreturn [String] payload to store and return on a miss
|
|
73
58
|
# @return [String] cached or freshly-computed payload
|
|
74
59
|
def fetch(url)
|
|
@@ -94,9 +79,8 @@ module Pikuri
|
|
|
94
79
|
File.join(@dir, "#{Digest::SHA256.hexdigest(url)}.txt")
|
|
95
80
|
end
|
|
96
81
|
|
|
97
|
-
# Null cache: a drop-in
|
|
98
|
-
#
|
|
99
|
-
# without giving up the {UrlCache#fetch} contract.
|
|
82
|
+
# Null cache: always misses, never persists — a drop-in for tests (or
|
|
83
|
+
# anywhere caching should be off) keeping the {UrlCache#fetch} contract.
|
|
100
84
|
NULL = Object.new
|
|
101
85
|
# Singleton +fetch+ on {NULL} that mirrors {UrlCache#fetch}'s shape:
|
|
102
86
|
# ignores +_key+, always yields, never persists.
|
data/lib/pikuri/version.rb
CHANGED