raxit
Capabilities

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_interactive

Two 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 init additionally generates one co-located denyWhenTainted block 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.

On this page