polyc_eventlog_model/taint.rs
1//! Storage-agnostic trust/provenance tags and the "lethal trifecta" detector.
2//!
3//! Every conversation [`Event`] carries a [`TrustTag`] assigned
4//! at ingress: an authenticated principal's own message is `trusted_user`,
5//! while tool output, fetched content, and otherwise untrusted inbound bodies
6//! are `quarantined_content`. The tag is a CaMeL-style provenance capability —
7//! it travels with the event through the durable log and is the substrate a
8//! data-flow security policy reasons over.
9//!
10//! [`trifecta_legs`] computes the **"lethal trifecta"** state for a
11//! conversation: the simultaneous presence of private-data access, untrusted
12//! content in the context window, and an external-communication capability.
13//! Each leg alone is safe; all three together give a prompt-injection payload
14//! both the data to steal and the channel to exfiltrate it. The untrusted
15//! leg is read straight from the event trust tags; the other two legs are
16//! supplied by the caller (deriving them from the live tool catalog, and
17//! acting on a live trifecta, is deliberately left to later enforcement work).
18
19use crate::Event;
20
21/// Trust/provenance capability tag attached to every conversation event at
22/// ingress.
23///
24/// The on-disk encoding is the single discriminant byte (see
25/// [`TrustTag::as_u8`]); the values are stable and mirrored by the
26/// `polychrome.events.v1.TrustTag` proto enum so the wire/forensics layer
27/// shares one vocabulary.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29#[repr(u8)]
30pub enum TrustTag {
31 /// Not yet classified — a control/marker event with no external content
32 /// provenance (turn markers, usage, model scaffolding). Neither a trusted
33 /// principal message nor untrusted content.
34 #[default]
35 Unspecified = 0,
36 /// An authenticated principal's own message (the user themself).
37 TrustedUser = 1,
38 /// Tool output, fetched web content, or otherwise untrusted inbound
39 /// content. Its mere presence in the log satisfies the trifecta's
40 /// untrusted-content-in-context leg.
41 QuarantinedContent = 2,
42}
43
44impl TrustTag {
45 /// The stable discriminant byte, as stored in the event log codec.
46 #[must_use]
47 pub const fn as_u8(self) -> u8 {
48 self as u8
49 }
50
51 /// Recover a [`TrustTag`] from its discriminant byte, or `None` if the
52 /// byte names no known tag (a corrupt or tampered log entry).
53 #[must_use]
54 pub const fn from_u8(byte: u8) -> Option<Self> {
55 match byte {
56 0 => Some(Self::Unspecified),
57 1 => Some(Self::TrustedUser),
58 2 => Some(Self::QuarantinedContent),
59 _ => None,
60 }
61 }
62
63 /// Whether this tag marks untrusted content (the trifecta's
64 /// untrusted-content-in-context leg).
65 #[must_use]
66 pub const fn is_quarantined(self) -> bool {
67 matches!(self, Self::QuarantinedContent)
68 }
69
70 /// Stable lowercase label for forensics rendering and logs.
71 #[must_use]
72 pub const fn as_str(self) -> &'static str {
73 match self {
74 Self::Unspecified => "unspecified",
75 Self::TrustedUser => "trusted_user",
76 Self::QuarantinedContent => "quarantined_content",
77 }
78 }
79}
80
81/// The three independent capability legs whose simultaneous presence in one
82/// conversation forms the "lethal trifecta" data-exfiltration path.
83///
84/// Any single leg alone is safe. All three together mean an injected payload
85/// in the untrusted content can read the private data and reach an external
86/// channel to leak it.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub struct TrifectaLegs {
89 /// The conversation can read private/sensitive data.
90 pub private_data_access: bool,
91 /// Untrusted (`quarantined_content`) content is in the context window.
92 pub untrusted_content_in_context: bool,
93 /// The conversation holds a capability to communicate externally.
94 pub external_comms_capability: bool,
95}
96
97impl TrifectaLegs {
98 /// `true` only when all three legs hold simultaneously — i.e. a live
99 /// exfiltration path exists and the turn should be downgraded.
100 #[must_use]
101 pub const fn is_live(self) -> bool {
102 self.private_data_access
103 && self.untrusted_content_in_context
104 && self.external_comms_capability
105 }
106}
107
108/// Capabilities granted to a turn that cannot yet be derived from the tagged
109/// event substrate alone — they require knowledge of the live tool catalog.
110///
111/// This foundation reads the untrusted-content leg from the event trust tags
112/// directly; classifying which granted tools constitute private-data access
113/// or an external-comms channel is the deferred enforcement work, so those two
114/// legs are supplied explicitly here.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub struct GrantedCapabilities {
117 /// A granted tool can read private/sensitive data.
118 pub private_data_access: bool,
119 /// A granted tool can communicate externally.
120 pub external_comms: bool,
121}
122
123/// Compute the [`TrifectaLegs`] for a conversation from its tagged event log
124/// and the capabilities granted to the turn.
125///
126/// The untrusted-content leg is read directly from the event trust tags (any
127/// [`TrustTag::QuarantinedContent`] event means untrusted content is in the
128/// context window). The private-data-access and external-comms legs come from
129/// `granted`, since classifying a tool's capabilities — and enforcing a
130/// downgrade when the result is live — is the deferred work this foundation
131/// sets up rather than performs.
132#[must_use]
133pub fn trifecta_legs(events: &[Event], granted: GrantedCapabilities) -> TrifectaLegs {
134 TrifectaLegs {
135 private_data_access: granted.private_data_access,
136 untrusted_content_in_context: any_untrusted(events),
137 external_comms_capability: granted.external_comms,
138 }
139}
140
141/// Whether any event in `events` carries untrusted ([`TrustTag::QuarantinedContent`])
142/// provenance — the durable form of the trifecta's untrusted-content-in-context
143/// leg, read straight from the trust tags.
144///
145/// The control plane uses this over a conversation's *full* durable log to seed
146/// the agent's enforcement gate, so untrusted content that history compaction
147/// folded out of the projected transcript (and is therefore invisible to the
148/// agent's structural in-memory check) still keeps the leg live. See the gate's
149/// `untrusted_context_seed` plumbing.
150#[must_use]
151pub fn any_untrusted(events: &[Event]) -> bool {
152 events.iter().any(|event| event.trust.is_quarantined())
153}
154
155/// [`any_untrusted`] over position-carrying events, excluding the journal
156/// positions a verified taint-excision marker covers (`#590`).
157///
158/// Excision recovers a conversation's grants by changing the *input* to this
159/// derivation, never the rule: a quarantined event at an excised position
160/// simply stops feeding the seed, exactly as if compaction had never folded
161/// it in. The caller (the control plane) verifies the markers and expands
162/// their scope into `excised` — this helper is pure set exclusion, so the
163/// monotonicity of the taint model is untouched. An empty `excised` set is
164/// byte-for-byte [`any_untrusted`].
165#[must_use]
166pub fn any_untrusted_excluding(
167 events: &[(u64, Event)],
168 excised: &std::collections::BTreeSet<u64>,
169) -> bool {
170 events
171 .iter()
172 .any(|(pos, event)| !excised.contains(pos) && event.trust.is_quarantined())
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::Event;
179
180 // Foundation TDD #1: an event created from tool output is tagged
181 // `quarantined_content`, and a user's own message `trusted_user`.
182 #[test]
183 fn ingress_tags_tool_output_quarantined_and_user_trusted() {
184 let tool_output = Event::quarantined("tool_result", b"<fetched page>".to_vec());
185 assert_eq!(tool_output.trust, TrustTag::QuarantinedContent);
186 assert!(tool_output.trust.is_quarantined());
187
188 let user_message = Event::trusted("user_msg", b"summarize my inbox".to_vec());
189 assert_eq!(user_message.trust, TrustTag::TrustedUser);
190 assert!(!user_message.trust.is_quarantined());
191
192 // An unclassified control event carries neither trust leg.
193 let marker = Event::new("turn_start", Vec::new());
194 assert_eq!(marker.trust, TrustTag::Unspecified);
195 }
196
197 // Foundation TDD #2: the lethal-trifecta state is live only when all three
198 // legs hold simultaneously over the tagged event set.
199 #[test]
200 fn trifecta_is_live_only_when_all_three_legs_hold() {
201 let clean = [Event::trusted("user_msg", b"hi".to_vec())];
202 let tainted = [
203 Event::trusted("user_msg", b"hi".to_vec()),
204 Event::quarantined("output_msg", b"<tool result>".to_vec()),
205 ];
206 let both = GrantedCapabilities {
207 private_data_access: true,
208 external_comms: true,
209 };
210
211 // All three legs present -> live.
212 assert!(trifecta_legs(&tainted, both).is_live());
213
214 // Drop any single leg -> not live.
215 assert!(
216 !trifecta_legs(&clean, both).is_live(),
217 "no untrusted content in context"
218 );
219 assert!(
220 !trifecta_legs(
221 &tainted,
222 GrantedCapabilities {
223 private_data_access: true,
224 external_comms: false,
225 }
226 )
227 .is_live(),
228 "no external comms capability"
229 );
230 assert!(
231 !trifecta_legs(
232 &tainted,
233 GrantedCapabilities {
234 private_data_access: false,
235 external_comms: true,
236 }
237 )
238 .is_live(),
239 "no private data access"
240 );
241 assert!(
242 !trifecta_legs(&tainted, GrantedCapabilities::default()).is_live(),
243 "no capabilities granted"
244 );
245
246 // The untrusted-content leg is read directly from the event trust tags.
247 assert!(trifecta_legs(&tainted, both).untrusted_content_in_context);
248 assert!(!trifecta_legs(&clean, both).untrusted_content_in_context);
249 }
250
251 // #590: an excised quarantined position stops feeding the seed; the
252 // derivation rule itself never weakens (pure set exclusion), and an
253 // empty exclusion set is exactly `any_untrusted`.
254 #[test]
255 fn excluded_positions_recover_the_seed() {
256 use std::collections::BTreeSet;
257 let events: Vec<(u64, Event)> = vec![
258 (0, Event::trusted("user_msg", b"hi".to_vec())),
259 (
260 1,
261 Event::quarantined("output_msg", b"<fetched page>".to_vec()),
262 ),
263 (2, Event::new("turn_complete", Vec::new())),
264 ];
265 let none = BTreeSet::new();
266 assert!(any_untrusted_excluding(&events, &none), "taint present");
267
268 let excised: BTreeSet<u64> = [1].into();
269 assert!(
270 !any_untrusted_excluding(&events, &excised),
271 "excising the quarantined position re-derives the seed clean"
272 );
273
274 // Excising an unrelated position changes nothing (fail closed), and
275 // fresh untrusted content after an excision re-taints as before.
276 let wrong: BTreeSet<u64> = [0, 2].into();
277 assert!(any_untrusted_excluding(&events, &wrong));
278 let mut later = events;
279 later.push((3, Event::quarantined("output_msg", b"<new fetch>".to_vec())));
280 assert!(any_untrusted_excluding(&later, &excised));
281 }
282
283 // `any_untrusted` is the durable seed the control plane reads over the full
284 // log: true iff some event is quarantined, regardless of trusted/marker
285 // events around it.
286 #[test]
287 fn any_untrusted_detects_a_single_quarantined_event() {
288 let clean = [
289 Event::trusted("user_msg", b"hi".to_vec()),
290 Event::new("turn_start", Vec::new()),
291 ];
292 assert!(!any_untrusted(&clean));
293 assert!(!any_untrusted(&[]));
294
295 let tainted = [
296 Event::trusted("user_msg", b"hi".to_vec()),
297 Event::new("turn_start", Vec::new()),
298 Event::quarantined("output_msg", b"<fetched page>".to_vec()),
299 ];
300 assert!(any_untrusted(&tainted));
301 }
302}