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

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).

Parameters:

  • agent (Pikuri::Agent)

    a wired agent (after the bind sweep, so dynamically-added tools are present)

  • label (String) (defaults to: File.basename($PROGRAM_NAME))

    name for the root node; defaults to the running program, which is what a host wants in a boot banner

Returns:



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

Fold a Node tree into a Report tree: each node's own tools unioned, then every sub-agent's legs propagated up.

Parameters:

  • node (Node)

    root of the tree, usually the main agent

Returns:

  • (Report)

    mirrors the input tree, legs folded



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