Class: Pikuri::Trifecta::Report
- Inherits:
-
Object
- Object
- Pikuri::Trifecta::Report
- Defined in:
- lib/pikuri/trifecta/report.rb
Overview
The result of walking one agent tree: a Node with its legs folded — its own tools unioned with everything propagated up from its sub-agents. What walk returns, mirroring the tree shape so a host can both classify and display it:
report = Pikuri::Trifecta.walk(node)
report.tree_verdict # => :soft
report.worst_path.map(&:label) # => ["pikuri-computer", "RESEARCHER"]
puts report.render # the human-readable map
Implementation details
#verdict is derived, never stored: Data#with would otherwise let a
copy carry a severity that contradicts its own legs. It reads presence
(leg-absence, correct for any level ever added) and matches strength
exhaustively across all three graded axes, so introducing a lattice level
without placing it in the grid raises on the first wiring that reaches it
rather than being silently absorbed into :soft.
Immutable.
Constant Summary collapse
- SEVERITY =
Verdicts, least to most severe. The only ordering outside Pikuri::Tool::TrifectaLegs.
i[silent soft loud].freeze
- FOOTER =
Closing pointer of #advisory. Names the chapter by slug, not by ordinal: book chapters are ordered only by
book/README.md, so this path survives a reordering that would silently falsify a number. 'See book/trifecta-detector.md in this repo.'
Instance Attribute Summary collapse
-
#channel_egress ⇒ Symbol?
readonly
The parent edge's gate level (see Node#channel_egress).
-
#children ⇒ Array<Report>
readonly
One per sub-agent, already walked.
-
#label ⇒ String
readonly
This agent's name (see Node#label).
-
#legs ⇒ Pikuri::Tool::TrifectaLegs
readonly
Folded legs — own tools plus everything propagated up from #children.
- #tool_legs ⇒ Hash{String=>Pikuri::Tool::TrifectaLegs} readonly
Instance Method Summary collapse
-
#advisory ⇒ String
The one-line conclusion: what was found, where, and the leg to break.
-
#render ⇒ String
The human-readable map: one line per agent and per tool, indented by depth, with each entry's legs as tags.
-
#tree_verdict ⇒ Symbol
The most severe #verdict anywhere in this subtree.
-
#verdict ⇒ Symbol
How dangerous this node is — the tree's verdict is #tree_verdict.
-
#worst_path ⇒ Array<Report>
Root-first path of Reports down to the most severe node in this subtree, so the caller can both attribute the finding and read its cell:.
Instance Attribute Details
#channel_egress ⇒ Symbol? (readonly)
Returns the parent edge's gate level (see Node#channel_egress).
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 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 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/pikuri/trifecta/report.rb', line 39 class Report < Data.define(:label, :legs, :tool_legs, :children, :channel_egress) # Verdicts, least to most severe. The only ordering outside # {Pikuri::Tool::TrifectaLegs}. # # @return [Array<Symbol>] SEVERITY = i[silent soft loud].freeze # Closing pointer of {#advisory}. Names the chapter by *slug*, not by # ordinal: book chapters are ordered only by `book/README.md`, so this # path survives a reordering that would silently falsify a number. # # @return [String] FOOTER = 'See book/trifecta-detector.md in this repo.' # How dangerous *this node* is — the tree's verdict is {#tree_verdict}. # # +:loud+ needs all three legs at full strength; +:soft+ is any complete # trifecta whose untrusted or egress leg is attenuated; +:silent+ is a # missing leg. Absence of a warning is not evidence of safety: this # reasons about capability presence, not data flow. # # @return [Symbol] +:silent+, +:soft+ or +:loud+ # @raise [NoMatchingPatternError] if a lattice level exists that this grid # does not place — deliberate, so a new level cannot be absorbed silently def verdict return :silent unless legs.private && legs.untrusted != :none && legs.egress? case [legs.untrusted, legs.egress_payload_review, legs.egress_destination] in [:hard, :unreviewed, :attacker_reachable] then :loud in [:hard, :unreviewed, :tool_vouched] then :soft in [:hard, :human_reviewed, :attacker_reachable] then :soft in [:hard, :human_reviewed, :tool_vouched] then :soft in [:weakened, :unreviewed, :attacker_reachable] then :soft in [:weakened, :unreviewed, :tool_vouched] then :soft in [:weakened, :human_reviewed, :attacker_reachable] then :soft in [:weakened, :human_reviewed, :tool_vouched] then :soft end end # Root-first path of Reports down to the most severe node in this subtree, # so the caller can both attribute the finding and read its cell: # # path = report.worst_path # path.map(&:label).join(' -> ') # => "pikuri-computer -> RESEARCHER" # path.last.legs # the cell that decided the verdict # # Ties go to the node encountered first in pre-order — the parent over its # own children, since a parent's trifecta is the more actionable finding. # # @return [Array<Report>] never empty; +[self]+ when nothing below is worse def worst_path best = [self] children.each do |child| candidate = [self, *child.worst_path] best = candidate if SEVERITY.index(candidate.last.verdict) > SEVERITY.index(best.last.verdict) end best end # @return [Symbol] the most severe {#verdict} anywhere in this subtree def tree_verdict = worst_path.last.verdict # The human-readable map: one line per agent and per tool, indented by # depth, with each entry's legs as tags. # # Trifecta: soft # # pikuri-computer [:private, :untrusted_hard, :egress_reviewed] # mail_compose [:private, :egress_reviewed] # calculator [] # RESEARCHER [gate: human_reviewed] [:untrusted_hard, :egress] # fetch [:untrusted_hard, :egress] # # See book/trifecta-detector.md in this repo for ... # # An agent line carries its *folded* legs and a tool line its own, so # propagation shows up as layout: the parent above holds egress that no # tool of its own contributes. Plain text by design — a host wanting color # or width walks this tree itself rather than asking for styled output. # # @return [String] newline-separated, no trailing newline def render [advisory, '', *render_lines].join("\n") end # The one-line conclusion: what was found, where, and the leg to break. # Separate from {#render} because a status line wants this without the # tree, and because it is the half that carries *meaning* — the map # deliberately carries only facts. # # [TRIFECTA: HARD] pikuri-code — private(read, bash) + untrusted(read, # bash) + egress(bash). An injection in what this agent reads can drive # an exfiltration. Break a leg: sever egress (a network-off sandbox), or # privilege-separate the step that reads untrusted content. # See book/trifecta-detector.md in this repo. # # A clean verdict never says "no trifecta detected". This reasons about # capability presence, so a quiet result is not a clean bill of health — # it under-warns on a single tool that is all three internally, and on # private data staged into a file by one tool and read by another. # Reporting the *finding* names what would tip the wiring over and claims # nothing it cannot support. # # @return [String] def advisory path = worst_path where = path.map(&:label).join(' -> ') legs = path.last.legs return "Trifecta: #{where} — #{presence(legs)}." if tree_verdict == :silent "[TRIFECTA: #{tree_verdict == :loud ? 'HARD' : 'soft'}] #{where} — " \ "#{attribution(path.last)}. An injection in what this agent reads can drive " \ "an exfiltration. Break a leg: #{advice(legs)}. #{FOOTER}" end protected # @param depth [Integer] indentation level # @return [Array<String>] def render_lines(depth = 0) indent = ' ' * depth gate = channel_egress ? " [gate: #{channel_egress}]" : '' lines = ["#{indent}#{label}#{gate} #{legs.to_tags.inspect}"] tool_legs.each { |name, tool| lines << "#{indent} #{name} #{tool.to_tags.inspect}" } children.each { |child| lines.concat(child.render_lines(depth + 1)) } lines end private # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] e.g. +"2 of 3 legs (private, untrusted); no egress leg found"+ def presence(legs) held = { 'private' => legs.private, 'untrusted' => legs.untrusted != :none, 'egress' => legs.egress? } "#{held.count { |_, v| v }} of 3 legs (#{held.select { |_, v| v }.keys.join(', ')}); " \ "no #{held.reject { |_, v| v }.keys.join('/')} leg found" end # Which tool contributed each leg, so the line names something the reader # can actually go and change. # # @param node [Report] # @return [String] def attribution(node) i[private untrusted egress].filter_map do |axis| contributors = node.tool_legs.select { |_, l| leg_present?(l, axis) }.keys "#{axis}(#{contributors.join(', ')})" unless contributors.empty? end.join(' + ') end # @param legs [Pikuri::Tool::TrifectaLegs] # @param axis [Symbol] +:private+, +:untrusted+ or +:egress+ # @return [Boolean] def leg_present?(legs, axis) case axis in :private then legs.private in :untrusted then legs.untrusted != :none in :egress then legs.egress? end end # The fix, per cell of the verdict grid — the soft cells want different # answers, which is why this reads the legs rather than the verdict symbol. # # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] def advice(legs) # A vouched destination is fragile in a way the other attenuations are # not: it survives only while it is the *sole* egress leg, since the # union hardens the whole node the moment one attacker-reachable tool # joins it. Say so, rather than let the soft verdict read as settled. if legs.egress_destination == :tool_vouched return 'sever egress outright — bytes still leave, they merely reach a destination the ' \ 'attacker cannot read back, and one attacker-reachable tool re-hardens this node' end case [legs.untrusted, legs.egress_payload_review] in [:hard, :unreviewed] 'sever egress (a network-off sandbox), or privilege-separate the step that reads untrusted content' in [:hard, _] 'privilege-separate the step that reads untrusted content, or declare the workspace trusted ' \ 'if you vouch for every byte it can reach' in [:weakened, :unreviewed] 'sever egress — the untrusted leg is already attenuated by delegation' in [:weakened, _] 'both legs are already attenuated; narrow what counts as private, or drop a leg outright' end end end |
#children ⇒ Array<Report> (readonly)
Returns one per sub-agent, already walked.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 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 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/pikuri/trifecta/report.rb', line 39 class Report < Data.define(:label, :legs, :tool_legs, :children, :channel_egress) # Verdicts, least to most severe. The only ordering outside # {Pikuri::Tool::TrifectaLegs}. # # @return [Array<Symbol>] SEVERITY = i[silent soft loud].freeze # Closing pointer of {#advisory}. Names the chapter by *slug*, not by # ordinal: book chapters are ordered only by `book/README.md`, so this # path survives a reordering that would silently falsify a number. # # @return [String] FOOTER = 'See book/trifecta-detector.md in this repo.' # How dangerous *this node* is — the tree's verdict is {#tree_verdict}. # # +:loud+ needs all three legs at full strength; +:soft+ is any complete # trifecta whose untrusted or egress leg is attenuated; +:silent+ is a # missing leg. Absence of a warning is not evidence of safety: this # reasons about capability presence, not data flow. # # @return [Symbol] +:silent+, +:soft+ or +:loud+ # @raise [NoMatchingPatternError] if a lattice level exists that this grid # does not place — deliberate, so a new level cannot be absorbed silently def verdict return :silent unless legs.private && legs.untrusted != :none && legs.egress? case [legs.untrusted, legs.egress_payload_review, legs.egress_destination] in [:hard, :unreviewed, :attacker_reachable] then :loud in [:hard, :unreviewed, :tool_vouched] then :soft in [:hard, :human_reviewed, :attacker_reachable] then :soft in [:hard, :human_reviewed, :tool_vouched] then :soft in [:weakened, :unreviewed, :attacker_reachable] then :soft in [:weakened, :unreviewed, :tool_vouched] then :soft in [:weakened, :human_reviewed, :attacker_reachable] then :soft in [:weakened, :human_reviewed, :tool_vouched] then :soft end end # Root-first path of Reports down to the most severe node in this subtree, # so the caller can both attribute the finding and read its cell: # # path = report.worst_path # path.map(&:label).join(' -> ') # => "pikuri-computer -> RESEARCHER" # path.last.legs # the cell that decided the verdict # # Ties go to the node encountered first in pre-order — the parent over its # own children, since a parent's trifecta is the more actionable finding. # # @return [Array<Report>] never empty; +[self]+ when nothing below is worse def worst_path best = [self] children.each do |child| candidate = [self, *child.worst_path] best = candidate if SEVERITY.index(candidate.last.verdict) > SEVERITY.index(best.last.verdict) end best end # @return [Symbol] the most severe {#verdict} anywhere in this subtree def tree_verdict = worst_path.last.verdict # The human-readable map: one line per agent and per tool, indented by # depth, with each entry's legs as tags. # # Trifecta: soft # # pikuri-computer [:private, :untrusted_hard, :egress_reviewed] # mail_compose [:private, :egress_reviewed] # calculator [] # RESEARCHER [gate: human_reviewed] [:untrusted_hard, :egress] # fetch [:untrusted_hard, :egress] # # See book/trifecta-detector.md in this repo for ... # # An agent line carries its *folded* legs and a tool line its own, so # propagation shows up as layout: the parent above holds egress that no # tool of its own contributes. Plain text by design — a host wanting color # or width walks this tree itself rather than asking for styled output. # # @return [String] newline-separated, no trailing newline def render [advisory, '', *render_lines].join("\n") end # The one-line conclusion: what was found, where, and the leg to break. # Separate from {#render} because a status line wants this without the # tree, and because it is the half that carries *meaning* — the map # deliberately carries only facts. # # [TRIFECTA: HARD] pikuri-code — private(read, bash) + untrusted(read, # bash) + egress(bash). An injection in what this agent reads can drive # an exfiltration. Break a leg: sever egress (a network-off sandbox), or # privilege-separate the step that reads untrusted content. # See book/trifecta-detector.md in this repo. # # A clean verdict never says "no trifecta detected". This reasons about # capability presence, so a quiet result is not a clean bill of health — # it under-warns on a single tool that is all three internally, and on # private data staged into a file by one tool and read by another. # Reporting the *finding* names what would tip the wiring over and claims # nothing it cannot support. # # @return [String] def advisory path = worst_path where = path.map(&:label).join(' -> ') legs = path.last.legs return "Trifecta: #{where} — #{presence(legs)}." if tree_verdict == :silent "[TRIFECTA: #{tree_verdict == :loud ? 'HARD' : 'soft'}] #{where} — " \ "#{attribution(path.last)}. An injection in what this agent reads can drive " \ "an exfiltration. Break a leg: #{advice(legs)}. #{FOOTER}" end protected # @param depth [Integer] indentation level # @return [Array<String>] def render_lines(depth = 0) indent = ' ' * depth gate = channel_egress ? " [gate: #{channel_egress}]" : '' lines = ["#{indent}#{label}#{gate} #{legs.to_tags.inspect}"] tool_legs.each { |name, tool| lines << "#{indent} #{name} #{tool.to_tags.inspect}" } children.each { |child| lines.concat(child.render_lines(depth + 1)) } lines end private # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] e.g. +"2 of 3 legs (private, untrusted); no egress leg found"+ def presence(legs) held = { 'private' => legs.private, 'untrusted' => legs.untrusted != :none, 'egress' => legs.egress? } "#{held.count { |_, v| v }} of 3 legs (#{held.select { |_, v| v }.keys.join(', ')}); " \ "no #{held.reject { |_, v| v }.keys.join('/')} leg found" end # Which tool contributed each leg, so the line names something the reader # can actually go and change. # # @param node [Report] # @return [String] def attribution(node) i[private untrusted egress].filter_map do |axis| contributors = node.tool_legs.select { |_, l| leg_present?(l, axis) }.keys "#{axis}(#{contributors.join(', ')})" unless contributors.empty? end.join(' + ') end # @param legs [Pikuri::Tool::TrifectaLegs] # @param axis [Symbol] +:private+, +:untrusted+ or +:egress+ # @return [Boolean] def leg_present?(legs, axis) case axis in :private then legs.private in :untrusted then legs.untrusted != :none in :egress then legs.egress? end end # The fix, per cell of the verdict grid — the soft cells want different # answers, which is why this reads the legs rather than the verdict symbol. # # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] def advice(legs) # A vouched destination is fragile in a way the other attenuations are # not: it survives only while it is the *sole* egress leg, since the # union hardens the whole node the moment one attacker-reachable tool # joins it. Say so, rather than let the soft verdict read as settled. if legs.egress_destination == :tool_vouched return 'sever egress outright — bytes still leave, they merely reach a destination the ' \ 'attacker cannot read back, and one attacker-reachable tool re-hardens this node' end case [legs.untrusted, legs.egress_payload_review] in [:hard, :unreviewed] 'sever egress (a network-off sandbox), or privilege-separate the step that reads untrusted content' in [:hard, _] 'privilege-separate the step that reads untrusted content, or declare the workspace trusted ' \ 'if you vouch for every byte it can reach' in [:weakened, :unreviewed] 'sever egress — the untrusted leg is already attenuated by delegation' in [:weakened, _] 'both legs are already attenuated; narrow what counts as private, or drop a leg outright' end end end |
#label ⇒ String (readonly)
Returns this agent's name (see Node#label).
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 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 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/pikuri/trifecta/report.rb', line 39 class Report < Data.define(:label, :legs, :tool_legs, :children, :channel_egress) # Verdicts, least to most severe. The only ordering outside # {Pikuri::Tool::TrifectaLegs}. # # @return [Array<Symbol>] SEVERITY = i[silent soft loud].freeze # Closing pointer of {#advisory}. Names the chapter by *slug*, not by # ordinal: book chapters are ordered only by `book/README.md`, so this # path survives a reordering that would silently falsify a number. # # @return [String] FOOTER = 'See book/trifecta-detector.md in this repo.' # How dangerous *this node* is — the tree's verdict is {#tree_verdict}. # # +:loud+ needs all three legs at full strength; +:soft+ is any complete # trifecta whose untrusted or egress leg is attenuated; +:silent+ is a # missing leg. Absence of a warning is not evidence of safety: this # reasons about capability presence, not data flow. # # @return [Symbol] +:silent+, +:soft+ or +:loud+ # @raise [NoMatchingPatternError] if a lattice level exists that this grid # does not place — deliberate, so a new level cannot be absorbed silently def verdict return :silent unless legs.private && legs.untrusted != :none && legs.egress? case [legs.untrusted, legs.egress_payload_review, legs.egress_destination] in [:hard, :unreviewed, :attacker_reachable] then :loud in [:hard, :unreviewed, :tool_vouched] then :soft in [:hard, :human_reviewed, :attacker_reachable] then :soft in [:hard, :human_reviewed, :tool_vouched] then :soft in [:weakened, :unreviewed, :attacker_reachable] then :soft in [:weakened, :unreviewed, :tool_vouched] then :soft in [:weakened, :human_reviewed, :attacker_reachable] then :soft in [:weakened, :human_reviewed, :tool_vouched] then :soft end end # Root-first path of Reports down to the most severe node in this subtree, # so the caller can both attribute the finding and read its cell: # # path = report.worst_path # path.map(&:label).join(' -> ') # => "pikuri-computer -> RESEARCHER" # path.last.legs # the cell that decided the verdict # # Ties go to the node encountered first in pre-order — the parent over its # own children, since a parent's trifecta is the more actionable finding. # # @return [Array<Report>] never empty; +[self]+ when nothing below is worse def worst_path best = [self] children.each do |child| candidate = [self, *child.worst_path] best = candidate if SEVERITY.index(candidate.last.verdict) > SEVERITY.index(best.last.verdict) end best end # @return [Symbol] the most severe {#verdict} anywhere in this subtree def tree_verdict = worst_path.last.verdict # The human-readable map: one line per agent and per tool, indented by # depth, with each entry's legs as tags. # # Trifecta: soft # # pikuri-computer [:private, :untrusted_hard, :egress_reviewed] # mail_compose [:private, :egress_reviewed] # calculator [] # RESEARCHER [gate: human_reviewed] [:untrusted_hard, :egress] # fetch [:untrusted_hard, :egress] # # See book/trifecta-detector.md in this repo for ... # # An agent line carries its *folded* legs and a tool line its own, so # propagation shows up as layout: the parent above holds egress that no # tool of its own contributes. Plain text by design — a host wanting color # or width walks this tree itself rather than asking for styled output. # # @return [String] newline-separated, no trailing newline def render [advisory, '', *render_lines].join("\n") end # The one-line conclusion: what was found, where, and the leg to break. # Separate from {#render} because a status line wants this without the # tree, and because it is the half that carries *meaning* — the map # deliberately carries only facts. # # [TRIFECTA: HARD] pikuri-code — private(read, bash) + untrusted(read, # bash) + egress(bash). An injection in what this agent reads can drive # an exfiltration. Break a leg: sever egress (a network-off sandbox), or # privilege-separate the step that reads untrusted content. # See book/trifecta-detector.md in this repo. # # A clean verdict never says "no trifecta detected". This reasons about # capability presence, so a quiet result is not a clean bill of health — # it under-warns on a single tool that is all three internally, and on # private data staged into a file by one tool and read by another. # Reporting the *finding* names what would tip the wiring over and claims # nothing it cannot support. # # @return [String] def advisory path = worst_path where = path.map(&:label).join(' -> ') legs = path.last.legs return "Trifecta: #{where} — #{presence(legs)}." if tree_verdict == :silent "[TRIFECTA: #{tree_verdict == :loud ? 'HARD' : 'soft'}] #{where} — " \ "#{attribution(path.last)}. An injection in what this agent reads can drive " \ "an exfiltration. Break a leg: #{advice(legs)}. #{FOOTER}" end protected # @param depth [Integer] indentation level # @return [Array<String>] def render_lines(depth = 0) indent = ' ' * depth gate = channel_egress ? " [gate: #{channel_egress}]" : '' lines = ["#{indent}#{label}#{gate} #{legs.to_tags.inspect}"] tool_legs.each { |name, tool| lines << "#{indent} #{name} #{tool.to_tags.inspect}" } children.each { |child| lines.concat(child.render_lines(depth + 1)) } lines end private # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] e.g. +"2 of 3 legs (private, untrusted); no egress leg found"+ def presence(legs) held = { 'private' => legs.private, 'untrusted' => legs.untrusted != :none, 'egress' => legs.egress? } "#{held.count { |_, v| v }} of 3 legs (#{held.select { |_, v| v }.keys.join(', ')}); " \ "no #{held.reject { |_, v| v }.keys.join('/')} leg found" end # Which tool contributed each leg, so the line names something the reader # can actually go and change. # # @param node [Report] # @return [String] def attribution(node) i[private untrusted egress].filter_map do |axis| contributors = node.tool_legs.select { |_, l| leg_present?(l, axis) }.keys "#{axis}(#{contributors.join(', ')})" unless contributors.empty? end.join(' + ') end # @param legs [Pikuri::Tool::TrifectaLegs] # @param axis [Symbol] +:private+, +:untrusted+ or +:egress+ # @return [Boolean] def leg_present?(legs, axis) case axis in :private then legs.private in :untrusted then legs.untrusted != :none in :egress then legs.egress? end end # The fix, per cell of the verdict grid — the soft cells want different # answers, which is why this reads the legs rather than the verdict symbol. # # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] def advice(legs) # A vouched destination is fragile in a way the other attenuations are # not: it survives only while it is the *sole* egress leg, since the # union hardens the whole node the moment one attacker-reachable tool # joins it. Say so, rather than let the soft verdict read as settled. if legs.egress_destination == :tool_vouched return 'sever egress outright — bytes still leave, they merely reach a destination the ' \ 'attacker cannot read back, and one attacker-reachable tool re-hardens this node' end case [legs.untrusted, legs.egress_payload_review] in [:hard, :unreviewed] 'sever egress (a network-off sandbox), or privilege-separate the step that reads untrusted content' in [:hard, _] 'privilege-separate the step that reads untrusted content, or declare the workspace trusted ' \ 'if you vouch for every byte it can reach' in [:weakened, :unreviewed] 'sever egress — the untrusted leg is already attenuated by delegation' in [:weakened, _] 'both legs are already attenuated; narrow what counts as private, or drop a leg outright' end end end |
#legs ⇒ Pikuri::Tool::TrifectaLegs (readonly)
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 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 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/pikuri/trifecta/report.rb', line 39 class Report < Data.define(:label, :legs, :tool_legs, :children, :channel_egress) # Verdicts, least to most severe. The only ordering outside # {Pikuri::Tool::TrifectaLegs}. # # @return [Array<Symbol>] SEVERITY = i[silent soft loud].freeze # Closing pointer of {#advisory}. Names the chapter by *slug*, not by # ordinal: book chapters are ordered only by `book/README.md`, so this # path survives a reordering that would silently falsify a number. # # @return [String] FOOTER = 'See book/trifecta-detector.md in this repo.' # How dangerous *this node* is — the tree's verdict is {#tree_verdict}. # # +:loud+ needs all three legs at full strength; +:soft+ is any complete # trifecta whose untrusted or egress leg is attenuated; +:silent+ is a # missing leg. Absence of a warning is not evidence of safety: this # reasons about capability presence, not data flow. # # @return [Symbol] +:silent+, +:soft+ or +:loud+ # @raise [NoMatchingPatternError] if a lattice level exists that this grid # does not place — deliberate, so a new level cannot be absorbed silently def verdict return :silent unless legs.private && legs.untrusted != :none && legs.egress? case [legs.untrusted, legs.egress_payload_review, legs.egress_destination] in [:hard, :unreviewed, :attacker_reachable] then :loud in [:hard, :unreviewed, :tool_vouched] then :soft in [:hard, :human_reviewed, :attacker_reachable] then :soft in [:hard, :human_reviewed, :tool_vouched] then :soft in [:weakened, :unreviewed, :attacker_reachable] then :soft in [:weakened, :unreviewed, :tool_vouched] then :soft in [:weakened, :human_reviewed, :attacker_reachable] then :soft in [:weakened, :human_reviewed, :tool_vouched] then :soft end end # Root-first path of Reports down to the most severe node in this subtree, # so the caller can both attribute the finding and read its cell: # # path = report.worst_path # path.map(&:label).join(' -> ') # => "pikuri-computer -> RESEARCHER" # path.last.legs # the cell that decided the verdict # # Ties go to the node encountered first in pre-order — the parent over its # own children, since a parent's trifecta is the more actionable finding. # # @return [Array<Report>] never empty; +[self]+ when nothing below is worse def worst_path best = [self] children.each do |child| candidate = [self, *child.worst_path] best = candidate if SEVERITY.index(candidate.last.verdict) > SEVERITY.index(best.last.verdict) end best end # @return [Symbol] the most severe {#verdict} anywhere in this subtree def tree_verdict = worst_path.last.verdict # The human-readable map: one line per agent and per tool, indented by # depth, with each entry's legs as tags. # # Trifecta: soft # # pikuri-computer [:private, :untrusted_hard, :egress_reviewed] # mail_compose [:private, :egress_reviewed] # calculator [] # RESEARCHER [gate: human_reviewed] [:untrusted_hard, :egress] # fetch [:untrusted_hard, :egress] # # See book/trifecta-detector.md in this repo for ... # # An agent line carries its *folded* legs and a tool line its own, so # propagation shows up as layout: the parent above holds egress that no # tool of its own contributes. Plain text by design — a host wanting color # or width walks this tree itself rather than asking for styled output. # # @return [String] newline-separated, no trailing newline def render [advisory, '', *render_lines].join("\n") end # The one-line conclusion: what was found, where, and the leg to break. # Separate from {#render} because a status line wants this without the # tree, and because it is the half that carries *meaning* — the map # deliberately carries only facts. # # [TRIFECTA: HARD] pikuri-code — private(read, bash) + untrusted(read, # bash) + egress(bash). An injection in what this agent reads can drive # an exfiltration. Break a leg: sever egress (a network-off sandbox), or # privilege-separate the step that reads untrusted content. # See book/trifecta-detector.md in this repo. # # A clean verdict never says "no trifecta detected". This reasons about # capability presence, so a quiet result is not a clean bill of health — # it under-warns on a single tool that is all three internally, and on # private data staged into a file by one tool and read by another. # Reporting the *finding* names what would tip the wiring over and claims # nothing it cannot support. # # @return [String] def advisory path = worst_path where = path.map(&:label).join(' -> ') legs = path.last.legs return "Trifecta: #{where} — #{presence(legs)}." if tree_verdict == :silent "[TRIFECTA: #{tree_verdict == :loud ? 'HARD' : 'soft'}] #{where} — " \ "#{attribution(path.last)}. An injection in what this agent reads can drive " \ "an exfiltration. Break a leg: #{advice(legs)}. #{FOOTER}" end protected # @param depth [Integer] indentation level # @return [Array<String>] def render_lines(depth = 0) indent = ' ' * depth gate = channel_egress ? " [gate: #{channel_egress}]" : '' lines = ["#{indent}#{label}#{gate} #{legs.to_tags.inspect}"] tool_legs.each { |name, tool| lines << "#{indent} #{name} #{tool.to_tags.inspect}" } children.each { |child| lines.concat(child.render_lines(depth + 1)) } lines end private # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] e.g. +"2 of 3 legs (private, untrusted); no egress leg found"+ def presence(legs) held = { 'private' => legs.private, 'untrusted' => legs.untrusted != :none, 'egress' => legs.egress? } "#{held.count { |_, v| v }} of 3 legs (#{held.select { |_, v| v }.keys.join(', ')}); " \ "no #{held.reject { |_, v| v }.keys.join('/')} leg found" end # Which tool contributed each leg, so the line names something the reader # can actually go and change. # # @param node [Report] # @return [String] def attribution(node) i[private untrusted egress].filter_map do |axis| contributors = node.tool_legs.select { |_, l| leg_present?(l, axis) }.keys "#{axis}(#{contributors.join(', ')})" unless contributors.empty? end.join(' + ') end # @param legs [Pikuri::Tool::TrifectaLegs] # @param axis [Symbol] +:private+, +:untrusted+ or +:egress+ # @return [Boolean] def leg_present?(legs, axis) case axis in :private then legs.private in :untrusted then legs.untrusted != :none in :egress then legs.egress? end end # The fix, per cell of the verdict grid — the soft cells want different # answers, which is why this reads the legs rather than the verdict symbol. # # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] def advice(legs) # A vouched destination is fragile in a way the other attenuations are # not: it survives only while it is the *sole* egress leg, since the # union hardens the whole node the moment one attacker-reachable tool # joins it. Say so, rather than let the soft verdict read as settled. if legs.egress_destination == :tool_vouched return 'sever egress outright — bytes still leave, they merely reach a destination the ' \ 'attacker cannot read back, and one attacker-reachable tool re-hardens this node' end case [legs.untrusted, legs.egress_payload_review] in [:hard, :unreviewed] 'sever egress (a network-off sandbox), or privilege-separate the step that reads untrusted content' in [:hard, _] 'privilege-separate the step that reads untrusted content, or declare the workspace trusted ' \ 'if you vouch for every byte it can reach' in [:weakened, :unreviewed] 'sever egress — the untrusted leg is already attenuated by delegation' in [:weakened, _] 'both legs are already attenuated; narrow what counts as private, or drop a leg outright' end end end |
#tool_legs ⇒ Hash{String=>Pikuri::Tool::TrifectaLegs} (readonly)
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 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 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/pikuri/trifecta/report.rb', line 39 class Report < Data.define(:label, :legs, :tool_legs, :children, :channel_egress) # Verdicts, least to most severe. The only ordering outside # {Pikuri::Tool::TrifectaLegs}. # # @return [Array<Symbol>] SEVERITY = i[silent soft loud].freeze # Closing pointer of {#advisory}. Names the chapter by *slug*, not by # ordinal: book chapters are ordered only by `book/README.md`, so this # path survives a reordering that would silently falsify a number. # # @return [String] FOOTER = 'See book/trifecta-detector.md in this repo.' # How dangerous *this node* is — the tree's verdict is {#tree_verdict}. # # +:loud+ needs all three legs at full strength; +:soft+ is any complete # trifecta whose untrusted or egress leg is attenuated; +:silent+ is a # missing leg. Absence of a warning is not evidence of safety: this # reasons about capability presence, not data flow. # # @return [Symbol] +:silent+, +:soft+ or +:loud+ # @raise [NoMatchingPatternError] if a lattice level exists that this grid # does not place — deliberate, so a new level cannot be absorbed silently def verdict return :silent unless legs.private && legs.untrusted != :none && legs.egress? case [legs.untrusted, legs.egress_payload_review, legs.egress_destination] in [:hard, :unreviewed, :attacker_reachable] then :loud in [:hard, :unreviewed, :tool_vouched] then :soft in [:hard, :human_reviewed, :attacker_reachable] then :soft in [:hard, :human_reviewed, :tool_vouched] then :soft in [:weakened, :unreviewed, :attacker_reachable] then :soft in [:weakened, :unreviewed, :tool_vouched] then :soft in [:weakened, :human_reviewed, :attacker_reachable] then :soft in [:weakened, :human_reviewed, :tool_vouched] then :soft end end # Root-first path of Reports down to the most severe node in this subtree, # so the caller can both attribute the finding and read its cell: # # path = report.worst_path # path.map(&:label).join(' -> ') # => "pikuri-computer -> RESEARCHER" # path.last.legs # the cell that decided the verdict # # Ties go to the node encountered first in pre-order — the parent over its # own children, since a parent's trifecta is the more actionable finding. # # @return [Array<Report>] never empty; +[self]+ when nothing below is worse def worst_path best = [self] children.each do |child| candidate = [self, *child.worst_path] best = candidate if SEVERITY.index(candidate.last.verdict) > SEVERITY.index(best.last.verdict) end best end # @return [Symbol] the most severe {#verdict} anywhere in this subtree def tree_verdict = worst_path.last.verdict # The human-readable map: one line per agent and per tool, indented by # depth, with each entry's legs as tags. # # Trifecta: soft # # pikuri-computer [:private, :untrusted_hard, :egress_reviewed] # mail_compose [:private, :egress_reviewed] # calculator [] # RESEARCHER [gate: human_reviewed] [:untrusted_hard, :egress] # fetch [:untrusted_hard, :egress] # # See book/trifecta-detector.md in this repo for ... # # An agent line carries its *folded* legs and a tool line its own, so # propagation shows up as layout: the parent above holds egress that no # tool of its own contributes. Plain text by design — a host wanting color # or width walks this tree itself rather than asking for styled output. # # @return [String] newline-separated, no trailing newline def render [advisory, '', *render_lines].join("\n") end # The one-line conclusion: what was found, where, and the leg to break. # Separate from {#render} because a status line wants this without the # tree, and because it is the half that carries *meaning* — the map # deliberately carries only facts. # # [TRIFECTA: HARD] pikuri-code — private(read, bash) + untrusted(read, # bash) + egress(bash). An injection in what this agent reads can drive # an exfiltration. Break a leg: sever egress (a network-off sandbox), or # privilege-separate the step that reads untrusted content. # See book/trifecta-detector.md in this repo. # # A clean verdict never says "no trifecta detected". This reasons about # capability presence, so a quiet result is not a clean bill of health — # it under-warns on a single tool that is all three internally, and on # private data staged into a file by one tool and read by another. # Reporting the *finding* names what would tip the wiring over and claims # nothing it cannot support. # # @return [String] def advisory path = worst_path where = path.map(&:label).join(' -> ') legs = path.last.legs return "Trifecta: #{where} — #{presence(legs)}." if tree_verdict == :silent "[TRIFECTA: #{tree_verdict == :loud ? 'HARD' : 'soft'}] #{where} — " \ "#{attribution(path.last)}. An injection in what this agent reads can drive " \ "an exfiltration. Break a leg: #{advice(legs)}. #{FOOTER}" end protected # @param depth [Integer] indentation level # @return [Array<String>] def render_lines(depth = 0) indent = ' ' * depth gate = channel_egress ? " [gate: #{channel_egress}]" : '' lines = ["#{indent}#{label}#{gate} #{legs.to_tags.inspect}"] tool_legs.each { |name, tool| lines << "#{indent} #{name} #{tool.to_tags.inspect}" } children.each { |child| lines.concat(child.render_lines(depth + 1)) } lines end private # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] e.g. +"2 of 3 legs (private, untrusted); no egress leg found"+ def presence(legs) held = { 'private' => legs.private, 'untrusted' => legs.untrusted != :none, 'egress' => legs.egress? } "#{held.count { |_, v| v }} of 3 legs (#{held.select { |_, v| v }.keys.join(', ')}); " \ "no #{held.reject { |_, v| v }.keys.join('/')} leg found" end # Which tool contributed each leg, so the line names something the reader # can actually go and change. # # @param node [Report] # @return [String] def attribution(node) i[private untrusted egress].filter_map do |axis| contributors = node.tool_legs.select { |_, l| leg_present?(l, axis) }.keys "#{axis}(#{contributors.join(', ')})" unless contributors.empty? end.join(' + ') end # @param legs [Pikuri::Tool::TrifectaLegs] # @param axis [Symbol] +:private+, +:untrusted+ or +:egress+ # @return [Boolean] def leg_present?(legs, axis) case axis in :private then legs.private in :untrusted then legs.untrusted != :none in :egress then legs.egress? end end # The fix, per cell of the verdict grid — the soft cells want different # answers, which is why this reads the legs rather than the verdict symbol. # # @param legs [Pikuri::Tool::TrifectaLegs] # @return [String] def advice(legs) # A vouched destination is fragile in a way the other attenuations are # not: it survives only while it is the *sole* egress leg, since the # union hardens the whole node the moment one attacker-reachable tool # joins it. Say so, rather than let the soft verdict read as settled. if legs.egress_destination == :tool_vouched return 'sever egress outright — bytes still leave, they merely reach a destination the ' \ 'attacker cannot read back, and one attacker-reachable tool re-hardens this node' end case [legs.untrusted, legs.egress_payload_review] in [:hard, :unreviewed] 'sever egress (a network-off sandbox), or privilege-separate the step that reads untrusted content' in [:hard, _] 'privilege-separate the step that reads untrusted content, or declare the workspace trusted ' \ 'if you vouch for every byte it can reach' in [:weakened, :unreviewed] 'sever egress — the untrusted leg is already attenuated by delegation' in [:weakened, _] 'both legs are already attenuated; narrow what counts as private, or drop a leg outright' end end end |
Instance Method Details
#advisory ⇒ String
The one-line conclusion: what was found, where, and the leg to break. Separate from #render because a status line wants this without the tree, and because it is the half that carries meaning — the map deliberately carries only facts.
[TRIFECTA: HARD] pikuri-code
A clean verdict never says "no trifecta detected". This reasons about capability presence, so a quiet result is not a clean bill of health — it under-warns on a single tool that is all three internally, and on private data staged into a file by one tool and read by another. Reporting the finding names what would tip the wiring over and claims nothing it cannot support.
143 144 145 146 147 148 149 150 151 152 |
# File 'lib/pikuri/trifecta/report.rb', line 143 def advisory path = worst_path where = path.map(&:label).join(' -> ') legs = path.last.legs return "Trifecta: #{where} — #{presence(legs)}." if tree_verdict == :silent "[TRIFECTA: #{tree_verdict == :loud ? 'HARD' : 'soft'}] #{where} — " \ "#{attribution(path.last)}. An injection in what this agent reads can drive " \ "an exfiltration. Break a leg: #{advice(legs)}. #{FOOTER}" end |
#render ⇒ String
The human-readable map: one line per agent and per tool, indented by depth, with each entry's legs as tags.
Trifecta: soft
pikuri-computer [:private, :untrusted_hard, :egress_reviewed]
mail_compose [:private, :egress_reviewed]
calculator []
RESEARCHER [gate: human_reviewed] [:untrusted_hard, :egress]
fetch [:untrusted_hard, :egress]
See book/trifecta-detector.md in this repo for ...
An agent line carries its folded legs and a tool line its own, so propagation shows up as layout: the parent above holds egress that no tool of its own contributes. Plain text by design — a host wanting color or width walks this tree itself rather than asking for styled output.
120 121 122 |
# File 'lib/pikuri/trifecta/report.rb', line 120 def render [advisory, '', *render_lines].join("\n") end |
#tree_verdict ⇒ Symbol
Returns the most severe #verdict anywhere in this subtree.
99 |
# File 'lib/pikuri/trifecta/report.rb', line 99 def tree_verdict = worst_path.last.verdict |
#verdict ⇒ Symbol
How dangerous this node is — the tree's verdict is #tree_verdict.
:loud needs all three legs at full strength; :soft is any complete
trifecta whose untrusted or egress leg is attenuated; :silent is a
missing leg. Absence of a warning is not evidence of safety: this
reasons about capability presence, not data flow.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/pikuri/trifecta/report.rb', line 63 def verdict return :silent unless legs.private && legs.untrusted != :none && legs.egress? case [legs.untrusted, legs.egress_payload_review, legs.egress_destination] in [:hard, :unreviewed, :attacker_reachable] then :loud in [:hard, :unreviewed, :tool_vouched] then :soft in [:hard, :human_reviewed, :attacker_reachable] then :soft in [:hard, :human_reviewed, :tool_vouched] then :soft in [:weakened, :unreviewed, :attacker_reachable] then :soft in [:weakened, :unreviewed, :tool_vouched] then :soft in [:weakened, :human_reviewed, :attacker_reachable] then :soft in [:weakened, :human_reviewed, :tool_vouched] then :soft end end |
#worst_path ⇒ Array<Report>
Root-first path of Reports down to the most severe node in this subtree, so the caller can both attribute the finding and read its cell:
path = report.worst_path
path.map(&:label).join(' -> ') # => "pikuri-computer -> RESEARCHER"
path.last.legs # the cell that decided the verdict
Ties go to the node encountered first in pre-order — the parent over its own children, since a parent's trifecta is the more actionable finding.
89 90 91 92 93 94 95 96 |
# File 'lib/pikuri/trifecta/report.rb', line 89 def worst_path best = [self] children.each do |child| candidate = [self, *child.worst_path] best = candidate if SEVERITY.index(candidate.last.verdict) > SEVERITY.index(best.last.verdict) end best end |