Policy¶
The policy decides whether a call is a violation. It is a Rego file,
collector/policy/pii_egress.rego, and it is the source of truth: the
collector evaluates it on every span. The MCP gateway implements the same
rules in Python (a test fails if the Python copy and the Rego file drift
apart), and the SDK sets its own taintward.violation flag for the egress
rule before the span leaves the process.
The rules¶
Input for one call:
{"sensitive": true, "classes": "pii,secret", "destination": "llm",
"jurisdiction": "us", "tool": "ask_llm"}
| rule | fires when | span attribute |
|---|---|---|
pii_egress |
sensitive is true and destination is external, llm or log |
taintward.policy.violation=true |
transfer |
classes has pii and jurisdiction is in non_adequate_jurisdictions (default {"us"}) |
taintward.policy.transfer_violation=true |
internal and rag are not violation sinks. They are still redacted
(tokens and masks), but sending sensitive data there is allowed.
The output is data.taintward.decision = {"redact": bool, "violations": [...]}.
redact is true for every sensitive span, so raw values are removed from
stored spans whether or not the call was a violation.
Who evaluates it¶
| where | when | what it does with the decision |
|---|---|---|
SDK (GuardedCall, adapters) |
before the call | redacts the input for the destination, sets taintward.violation for the egress rule (the transfer rule is left to the collector) |
| MCP gateway | before and after the upstream call | redacts, blocks or only records, per tool mode |
collector (taint_policy processor) |
when the span arrives | writes taintward.policy.*, removes raw values still on the span |
The collector's answer is the authoritative one in the evidence report
(decided_by: collector). It also drops the taintward.policy.* attributes it owns if
a sender set them, so a forged violation=false does not survive. If the
policy fails to evaluate, the collector fails closed: it masks everything it
can and writes taintward.policy.error.
Modes¶
The decision is the same everywhere. What happens next depends on the mode:
| mode | where | sink receives | violation recorded |
|---|---|---|---|
| redact (default) | SDK, gateway | redacted values | yes |
| block | gateway | nothing, the call is refused with MCP error -32001 |
yes, taintward.blocked=true |
| audit | gateway mode: audit, SDK TAINTWARD_DRY_RUN=1 |
raw values | yes, taintward.dry_run=true |
Audit mode is for rollout only. See Audit mode first, then enforce.
Changing the policy¶
Edit the Rego file and restart the collector (no rebuild), or pass your own
file to the Helm chart with --set-file collector.policy.rego=my.rego. Keep
these parts, which the processor checks at startup:
package taintward- a
decisionrule anddefault redact := false
Common changes:
- add a jurisdiction to
non_adequate_jurisdictions; - add
ragtosensitive_sinksif your vector store is run by a third party; - add a rule on
input.toolfor a tool that must never seesecretdata.
If you change the sink set, the jurisdiction set or the rule names, the
gateway copy in taintward/gateway/policy.py must change too (the SDK's own
egress set is _EGRESS_SINKS in taintward/sdk/instrumentation.py). The
test in tests/test_gateway.py tells you when they differ.
No LLM in the decision¶
The research model Taintward is based on (AgentRaft) asks a committee of three LLMs whether each data flow was necessary. Taintward replaces that with this deterministic policy: the same input always gives the same decision, it costs far less than one LLM call, and an auditor can read it. See AgentRaft mapping for the full comparison.