Module: Pikuri::Trifecta
- Defined in:
- lib/pikuri/trifecta.rb,
lib/pikuri/trifecta/node.rb,
lib/pikuri/trifecta/report.rb,
lib/pikuri/trifecta/contribution.rb
Overview
The lethal-trifecta detector: does one agent simultaneously hold private data, exposure to attacker-influenceable content, and a way to send bytes out? Any two legs are fine; all three is the live wire, because an injection hidden in the untrusted content can drive the exfiltration.
Build a Node tree — the agent, its tools, its sub-agents — and walk it:
report = Pikuri::Trifecta.walk(node)
report.tree_verdict # => :loud
puts report.render # the map a bin script prints
Nothing here reports itself. Core computes the picture and stops; surfacing it belongs to whoever has a screen (Agent#trifecta is the reader). A library writing to a log its embedder never asked for is noise, and a once-per-process guard would be worse than silence in a host that builds two differently-wired agents: it would report the first and stay quiet about the second, which reads as coverage.
This is a smoke alarm, not a firewall
It reasons about capability presence, never data flow, so it both over-warns (an egress query too narrow to carry anything) and under-warns (one tool that is all three internally; private data staged into a file by one tool and read out by another). It can prove you hold the ingredients. It can prove neither that you are exploitable nor that you are safe — which is why nothing here ever blocks execution, and why the absence of a warning is never reported as an assurance.
Implementation details
Trifecta.walk is a pure function of the tree, so the whole rule set is testable from hand-built Nodes with no agent, no LLM and no sandbox probe.
Two operations move legs between nodes, and their asymmetry is the design:
max in parallel (legs meeting at one node, Pikuri::Tool::TrifectaLegs#|)
and min in series (a leg squeezed through a delegation channel,
Pikuri::Tool::TrifectaLegs#cap_payload_review). Severity is evaluated at every node
rather than only at the root, and the tree's verdict is the max over all of
them — which localizes a finding to the agent that actually holds it instead
of smearing a sub-agent's danger across its parent.
Defined Under Namespace
Classes: Contribution, Node, Report
Class Method Summary collapse
-
.build(agent, label: File.basename($PROGRAM_NAME)) ⇒ Node
Assemble the tree for a wired agent: its own tools, plus whatever each extension contributes (Agent::Extension#trifecta_contribution — legs no tool can declare, and a child per sub-agent persona).
- .walk(node) ⇒ Report
Class Method Details
.build(agent, label: File.basename($PROGRAM_NAME)) ⇒ Node
Assemble the tree for a wired agent: its own tools, plus whatever each extension contributes (Agent::Extension#trifecta_contribution — legs no tool can declare, and a child per sub-agent persona).
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/pikuri/trifecta.rb', line 55 def build(agent, label: File.basename($PROGRAM_NAME)) # The root node holds +tools+ ONLY. +sub_agent_tools+ are registered for # personas and never reach the parent's LLM — crediting the parent with # them reports the sub-agent quarantine as broken, which is the one # false alarm guaranteed to get this advisory muted, since that # quarantine is the defense the loudest wirings are built around. # # The *hook* gets both, because a persona resolves its +tool_names+ # against either list. # # A tool neither list places — registered on the sub-agent pool and # named by no persona — therefore appears nowhere in the tree, and gets # no line of its own. Deliberate: nothing here can call it, so it is the # absence of a capability rather than one, and it can never explain a # verdict. The reverse direction *is* a bug and fails loudly elsewhere — # {Pikuri::SubAgent::Extension#configure} raises on a persona naming an # unregistered tool. See +D_trifecta_map_orphans+. contributions = agent.extensions.filter_map do |ext| if ext.respond_to?(:trifecta_contribution) ext.trifecta_contribution(agent.tools + agent.sub_agent_tools) end end tool_legs = agent.tools.to_h { |t| [t.name, t.trifecta_legs] } contributions.each do |contribution| next if contribution.legs == Tool::TrifectaLegs::NONE previous = tool_legs[contribution.label] || Tool::TrifectaLegs::NONE tool_legs[contribution.label] = previous | contribution.legs end Node.new(label: label, tool_legs: tool_legs, children: contributions.flat_map(&:children)) end |
.walk(node) ⇒ Report
95 96 97 98 99 100 101 102 |
# File 'lib/pikuri/trifecta.rb', line 95 def walk(node) children = node.children.map { |child| walk(child) } legs = node.tool_legs.values.reduce(Tool::TrifectaLegs::NONE) { |acc, tool| acc | tool } legs = children.reduce(legs) { |acc, child| acc | propagated(child) } Report.new(label: node.label, legs: legs, tool_legs: node.tool_legs, children: children, channel_egress: node.channel_egress) end |