Skip to main content

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}