Taint & the lethal trifecta
Cross-call taint tracking: how raxit breaks the lethal trifecta and stops prompt-injection-driven data exfiltration.
Take a 3-tool agent: calc (harmless), read_customer_db (returns customer PII, including an
SSN), post_to_webhook (POSTs to an external URL). Called alone, read_customer_db is fine —
reading your own database isn't a security event. Called alone, post_to_webhook is fine too —
POSTing somewhere is a normal integration. The dangerous case is only visible in composition:
an agent that reads PII and then has a path to send it externally, in the same session.
>>> agent calls read_customer_db({'query': 'select * from customers limit 1'})
<<< result: row1: name=Jane Doe, email=jane@example.com, ssn=123-45-6789
>>> agent calls post_to_webhook({'url': 'https://example.com/hook', 'payload': 'hi'})Read alone, read_customer_db permits. But once that call has run, the session carries PII in
its taint set — and a rule like this:
- effect: block
resource: post_to_webhook
denyWhenTainted: [PII]denies the second call, specifically because of what the first one returned — not because
post_to_webhook is inherently dangerous. That's the lethal trifecta: read access to private
data, exposure to untrusted content, and an external communication channel, present together in
one session. Any one alone is normal agent behavior; the combination is what raxit exists to
break.
The mental model: taint is session state, not per-call state
Cedar alone is stateless — a single-call policy has no memory of what happened earlier in the
session. raxit makes it a stateful reference monitor by feeding accumulated session facts
into every subsequent call's Cedar context:
@id("trifecta-pii-exfil")
forbid (
principal,
action == Action::"http:POST",
resource
)
when { context.external && context.taints.contains("PII") };context.taints is engine-owned and populated from the session's real history — never something
the agent can assert or spoof. Once a call is classified as introducing a label, that label
stays attached to the session for the rest of its lifetime (or until the taint rule stops
matching); it isn't cleared automatically after one deny.
Declaring what taints what
classify:
"read_customer_*": [PII]
"stripe/*": [FINANCIAL]classify: maps a resource glob to the labels it introduces when called. Combine with
denyWhenTainted on the rule that represents your egress/exfil surface, per
Rules & effects.
The redaction floor: on by default, zero configuration
Every governed tool's return value is scanned for recognizable PII classes — emails, SSNs, and
credit-card numbers, alongside common secret shapes (API keys, tokens, database URIs) — and a
match is rewritten in place ([REDACTED:pii_email], [REDACTED:pii_ssn], …) before the value
is recorded or returned, with no security.yaml entry required. This is the built-in output
scanner's default rule set, not the classify:/redact: mechanisms below — those add
resource-specific taint tagging (classify:) or argument redaction (redact:) on top of this
floor, they don't turn it on. The floor can be narrowed or disabled via postcondition: in
security.yaml (see Advanced) if a deployment needs different
coverage.
Taint-denial posture: deny vs. redact-and-permit
Boundary redaction (the in-flight [REDACTED:pii_email]-style rewrite shown in the
lethal-trifecta demo) always applies to values the classifier
tags. Whether a tainted session's call itself gets denied is a separate, explicit choice —
the Taint-Denial Posture. raxit init records it, every run, as a taint_denial: block:
taint_denial:
posture: redact_permit
selected_by: default_non_interactiveTwo postures:
redact_permit(the default) — nothing denies purely because of taint. Redaction still happens at the boundary, but a tainted session can still reach an egress-capable tool; only its already-redacted arguments go out.strict— opt-in.raxit initadditionally generates one co-locateddenyWhenTaintedblock rule per discovered egress-capable tool that already has an allow/review rule, e.g.:
- effect: allow
resource: post_to_webhook
- effect: block
resource: post_to_webhook
denyWhenTainted: [PII, SECRET, FINANCIAL, PHI, CREDENTIAL]selected_by records how the posture was chosen — prompt, flag, default_yes,
default_non_interactive, or default_no_answer — so the choice is always self-explanatory
from the file alone. See raxit init for the exact interactive prompt, the
--taint-denial-posture flag, and the full non-interactive decision table.
strict is opt-in and never a silent default flip — redact_permit remains what raxit init
writes unless you ask for strict (via the prompt or --taint-denial-posture strict), and
--yes/-y always accepts the default rather than implicitly choosing strict. The generated
denyWhenTainted guard also only fires on the governed tool.call path — the same
resourceType: tool surface the trifecta demo exercises — so it protects calls made through
governed_tool/the daemon, not an unwrapped direct call that bypasses governance entirely.
The label lattice
The closed vocabulary is 7 labels: PII, SECRET, FINANCIAL, CREDENTIAL, PHI,
PROMPT_INJECTION, COST_ANOMALY. Two implications hold in the lattice: CREDENTIAL implies
SECRET, and PHI implies PII — a session tainted CREDENTIAL is also treated as tainted
SECRET for matching purposes, and likewise for PHI/PII.
Detection today is structural — regex and Luhn-style pattern matching (classification: provider: local, the sealed floor) — not a semantic model. PHI and PROMPT_INJECTION are
declared, valid labels in the vocabulary and lattice, but nothing in the shipped classifier
actually sniffs for them yet — you can reference them in denyWhenTainted, but nothing will
tag a session with them on its own unless you wire a classify: glob or a custom classifier.
Treat PII, SECRET, FINANCIAL, and CREDENTIAL (pattern-detectable data) as the labels with
real, working automatic detection today.
Where this fits in the decision pipeline
Taint accumulation happens before Cedar evaluation — by the time your denyWhenTainted rule
runs, context.taints already reflects everything the session has touched so far. The
accumulated facts (taint, prior actions, original request) are also written into the
hash-chained audit record for every call, so a taint-triggered deny is fully explainable after
the fact — see Audit & the DPR chain.

