Demo: automatic PII redaction
A send_email carrying a raw card number is rewritten in-flight: the engine returns MODIFY with redacted args, the tool runs on those, and the PII never leaves — a real, executed demo.
When an agent emails a receipt whose body contains a raw credit-card number, refusing the call
breaks the workflow but permitting it as-is leaks PII. This demo shows the middle path: a
modify: rule makes the engine return Decision.type == "MODIFY" with modified_args, and
governed_tool runs the tool body on the engine's redacted arguments, never the raw ones —
the email still sends, the card number never leaves. Every terminal line below is from a real
execution, not a mock.
Run it yourself
From a clone of the repo, one make target builds the local engine, mints a throwaway Ed25519
signing key, starts raxit serve signing every record, runs the agent, asserts the reason code,
and tears the daemon down:
git clone https://github.com/raxitlabs/runtime-security-core
cd runtime-security-core
make -C examples demo-modify-redaction-pyThe demo lives in examples/capabilities/modify-redaction/ — a single modify_redaction.py plus
its .raxit/security.yaml. It needs a Go toolchain (to build the engine) and
uv; no API keys, no model credentials.
To use the same pattern in your own agent, install the SDK (pip install raxit) and wrap your
tools with governed_tool as shown below.
Python only
This demo has an executed Python transcript. There is no equivalent captured TypeScript transcript for the MODIFY path, so this page makes no claim about it.
The walkthrough
Step through the real code and the real output from the captured run:
One governed tool, one client. The agent calls send_email with a raw credit-card number in the body — the classic accidental-PII-egress moment.
client = RaxitClient()
@governed_tool("send_email", client)
def send_email(to: str, subject: str, body: str) -> str:
return f"send_email OK — sent to {to} subject={subject!r} body={body!r}"
args = {"to": "a@b.c", "subject": "receipt", "body": "card 4242424242424242"}How it works
The whole integration is one decorator and one client:
from raxit import RaxitClient
from raxit.agent import governed_tool
client = RaxitClient()
@governed_tool("send_email", client)
def send_email(to: str, subject: str, body: str) -> str:
return f"send_email OK — sent to {to} subject={subject!r} body={body!r}"
args = {"to": "a@b.c", "subject": "receipt", "body": "card 4242424242424242"}
out = send_email(**args)The policy is a single rule. Note it carries modify: instead of an effect: — the two are
mutually exclusive in the security.yaml grammar:
apiVersion: raxit.security/v1
agent: modify-bot
default: block
rules:
- resource: send_email # MODIFY: redact PII in the body before sending
modify: { redact: [body] }The sequence, per the transcript:
send_email→ MODIFY, enforced. The rule matches, so the engine returns a permit-shaped decision withtype=MODIFY,reason=modify.policy, andmodified_argsin which the body's card number has been rewritten to[REDACTED:pii_credit_card].governed_toolmergesmodified_argsover the caller's args and runs the tool body on the result — the transform is applied by the wrapper, not merely suggested to it. The exact redaction markup is the engine's own classifier output, not scripted by the example.- The decision, inspected directly. The example repeats the same call through
client.govern.decide(...)and printsdecision.modified_args["body"]— the literalcard [REDACTED:pii_credit_card]string, straight from the engine. - Audit verifies green. Both decisions were fsync'd to a hash-chained, Ed25519-signed
Decision Provenance log before each verdict returned.
client.audit.verify(signatures=True, pubkeys=[…])checks the chain and signatures against the pinned public key:ok=True signatures_verified=True signed_count=2 count=2. See Audit & the DPR chain.
MODIFY fails closed
modify is permit-with-transformation, not a fourth verdict — the decision vocabulary stays
permit | defer | deny. That makes the transform load-bearing: a MODIFY decision that arrives
without applicable modified_args is not forwarded with the raw arguments — the call fails
closed to deny.modify_error instead. See
Permit / defer / deny / modify / step-up for the resolution table.
What got modified — and what would be denied
Nothing was denied in this run, and that is the point of MODIFY: the reason code the CI oracle
asserts is modify.policy, a permit that carries a mandatory transform. Verbatim from the run:
decide() -> type=MODIFY reason=modify.policy modified_args.body='card [REDACTED:pii_credit_card]'
[PASS] modify.policy: engine returned MODIFY with a redacted body — card [REDACTED:pii_credit_card]Two denial paths still guard this policy, even though neither fired here:
default: block— any tool other thansend_emailhas no matching rule and is denied outright. The agent would see a[raxit BLOCKED] ...string in place of the tool's result.deny.modify_error— if the declared transform cannot be applied, the engine denies rather than silently forwarding the unredacted arguments.
Contrast this with the lethal-trifecta demo, where redaction
happens on a tool's output (a db.query result) and feeds session taint. Here the redaction
happens on the arguments — the data about to leave through send_email — before the call ever
executes.
Related
- Permit / defer / deny / modify / step-up — where MODIFY sits in
the decision vocabulary, and the
deny.modify_errorfail-closed path. - Rules & effects — the
modify: { <op>: [<arg-path>, ...] }grammar and whyeffect:andmodify:are mutually exclusive. - Audit & the DPR chain — the signed hash-chain verified in step 3.
- Demo: lethal trifecta — output-side redaction plus taint-driven egress denial.
Human approval loop
A real defer → approve → permit run: a policy-deferred refund waits for a human, an operator approves it, and the agent resumes to permit — with the signed audit chain verified.
Permit / defer / deny / modify / step-up
The five decision outcomes: permit, defer, deny, modify, step-up.

