Phaethon Security · Practitioner Field Guide
Writing Agent Guardrails with Ordinance
A practitioner's framework for policies that actually constrain an AI agent — and deploying them safely, with audit evidence, in CMMC / NIST 800-171 / CUI environments.
Who this is for, and what you'll be able to do
You're about to let an AI agent do things — export data, create accounts, call tools, invoke models — inside an environment where "the agent did something it shouldn't have" is a compliance incident, not just a bug. This framework shows you how to write Ordinance policies that constrain what an agent may do, and — just as important — how to deploy them so the guardrail actually holds.
By the end you'll be able to: model your sensitive actions as policy, write rules that fail safe, wire enforcement so nothing bypasses it, prove your policy works with an adversarial test, and produce the audit evidence an assessor asks for.
Read the mental model first (5 minutes). It's the difference between a guardrail that holds and one that only looks like it does.
Part 1 — The mental model
Ordinance decides; your integration enforces; trusted context is the linchpin. Three ideas, and everything else follows.
- It's a decision, not a wall. Ordinance's
/v1/evaluateanswers one question about one action: ALLOW, DENY, or ESCALATE (route to a human). It does not, by itself, block anything — your code enforces by honoring the answer. Phaethon ships a one-line enforcement client (enforce()/@guard) so honoring it is trivial, but the guarantee is only as strong as the gate you put around the action. Think of Ordinance as the policy brain; the enforcement point around your tool is the muscle. A brain with no muscle advises; a muscle with no brain flails. You need both. - The engine only knows what you tell it — and it believes you. Decisions are computed from a
contextobject you supply. If your caller saysmfa_verified: true, Ordinance takes that as true. It has no way to tell a real fact from a fabricated one. This is the single most important thing to get right: the facts that drive a decision must come from a trusted source (your identity provider, your DLP scanner, your platform) — never from the agent being governed, which can simply assert whatever gets it to ALLOW. (This is normal for every policy engine — OPA, Cedar, Ordinance alike. Trusting the input is the enforcement point's job, and that job is yours.) - Unknown is not the same as allowed. Ordinance treats a missing required fact as ESCALATE ("you didn't tell me, so a human decides"), a present-but-wrong fact as DENY, and only allows when every requirement is explicitly satisfied. Silence never buys a yes. This is what makes it safe by default — but it only works if you model the facts, so an unmodeled risk escalates rather than slips through.
Everything below is the practice of those three ideas.
Part 2 — The method (a repeatable eight-step loop)
Do this per sensitive capability you're giving an agent. It's a loop, not a waterfall — step 7 (adversarial test) usually sends you back to step 3.
Step 1 — Inventory the sensitive actions
List what the agent can do that could cause a compliance or security problem: export data, create/modify accounts, change configuration, spend money, invoke a model on sensitive input, reach an external system. Each becomes a policy action (a stable string like data.export_cui, account.create_privileged, ai.model_invoke). Name them by capability, not by tool — several tools may map to one governed action.
Step 2 — For each action, define the trusted facts the decision turns on
This is the heart of the work. For each action ask: what must be true for this to be safe, and where does each of those facts come from? Write them as a small table — this table is your policy, and your security:
| Fact (context key) | What it means | Trusted source (not the agent) |
|---|---|---|
dlp_scan_passed | The payload cleared DLP | Your DLP engine's real result |
destination_approved | Target is on the allowlist | Your platform's allowlist check |
mfa_verified | The initiating human passed MFA | Your IdP / session, not a flag the agent sets |
If a fact's only possible source is "the agent tells us," it is not a safe basis for ALLOW — model it so its absence escalates, and get a trusted source before you rely on it.
Step 3 — Write the rule
An Ordinance rule has: an action, a plain-language description (shown verbatim in DENY/ESCALATE reasons — write it for the human who'll read the denial), a controls list (the compliance controls this rule evidences), and the conditions:
- requires — facts that must all be explicitly satisfied to ALLOW. Missing → ESCALATE; present-and-wrong → DENY.
- escalate_if — facts that force human review independently, even when every requirement is met (a high-risk target, a restricted destination).
Each condition names a key plus at most one operator:
| Operator | Satisfied when | Use for |
|---|---|---|
equals: <v> | value equals v exactly (type-exact: true ≠ "true") | boolean / known-value checks |
in: [<v>, …] | value is one of the listed values | allowlists (regions, roles) |
| (none) | the key is simply present (any value) | "a reviewer is on record" |
Step 4 — Keep the default fail-safe
Ordinance ships with default_decision: escalate: an action that no rule matches is escalated, not silently allowed to pass. Leave this on. (The response also flags policy_match: false so "no policy yet" is never confused with "evaluated and fine.")
Step 5 — Validate before you ship
A typo like require for requires, or equal for equals, silently becomes a no-op that quietly changes decisions. Catch it:
python -m app.validate policies
# or: docker compose run --rm ordinance python -m app.validateThis lints for unknown fields, rules with no action (which can never match), non-list in, and conflicting operators. Run it in CI as a pre-ship gate; --strict fails on warnings too.
Step 6 — Wire enforcement so nothing bypasses it
Gate the action on the decision, and make sure the agent has no other path to the underlying capability:
from ordinance_client import OrdinanceClient, Denied, EscalationRequired
ord = OrdinanceClient()
def run_tool(name, args):
action, build_context = TOOL_POLICY[name] # map tool -> action + trusted context
try:
ord.enforce(action, context=build_context(args)) # raises on DENY / ESCALATE
except Denied as e:
return {"blocked": True, "reason": e.reasons}
except EscalationRequired as e:
return {"blocked": True, "approval_id": e.approval_id}
return {"ran": True, "result": TOOLS[name](args)} # runs ONLY on ALLOWThe rule that makes this real: the agent must not be able to reach the tool except through this gate — no raw shell, no direct API client, no un-gated function. If it can, the guardrail is decorative.
Step 7 — Test adversarially (prove it, don't assume it)
Before you trust a policy, try to beat it. Run each action through these and confirm the outcome:
- Happy path — all facts present and satisfied → ALLOW.
- Refusal — a required fact present but wrong → DENY.
- Omission — a required fact missing → ESCALATE (never ALLOW).
- High-risk trigger — requirements met but an
escalate_iffires → ESCALATE. - The lie — supply the facts as the agent would and confirm you're only trusting facts a trusted source actually set. If the agent can self-assert its way to ALLOW, fix step 2, not the policy.
- The bypass — call the tool without going through the gate. If it runs, your enforcement wiring (step 6) has a hole.
Step 8 — Export the evidence
Every decision — ALLOW, DENY, ESCALATE — is logged with its control citations. Produce the assessor artifacts on demand:
curl -s -H "X-Api-Key: $KEY" "$URL/v1/audit/export?format=zip" -o ordinance-evidence.zip
# decision log (CSV) + control x decision matrix (CSV) + READMEThe control matrix seeds every control your policy references, so a control that was never exercised shows a visible 0 — a coverage gap you can see, not a silent omission.
Part 3 — Design patterns (the idioms that work)
- "Unknown escalates." Model every decision-critical fact as a
requireskey, so its absence escalates. Don't leave a risk unmodeled and hope — an unmodeled fact you never ask for can't stop anything. - Allowlist with
in. Regions, roles, destinations:{ key: region, in: ["us-east","us-gov"] }beats a wall ofequalsrules. - Presence-only for "a human is on record."
{ key: reviewer }requires some reviewer without constraining who — useful for accountability without over-modeling. escalate_iffor "allowed, but not silently." High-risk targets (domain-admin role, restricted destination, unreviewed frontier model) should get a second set of eyes even when every requirement is met.- Layer with most-restrictive-wins. When several rules match one action, the strongest outcome wins (DENY > ESCALATE > ALLOW). Write a broad baseline rule and a narrow high-risk rule; the narrow one can veto without touching the baseline.
- Evidence on allow, too. Attach
controlsto every rule, not just denials — a clean ALLOW that satisfies a control is evidence the control operated. - Introspect before acting. Agents can call
GET /v1/policies/applicable?action=…to learn what an action requires and gather the missing (trusted) facts before attempting it, rather than discovering requirements by being denied.
Part 4 — Anti-patterns (how it goes wrong)
- Letting the agent supply its own decision facts. The fatal mistake. If the thing being governed populates
mfa_verified/dlp_scan_passed, it can lie to ALLOW. Facts come from trusted sources. (Part 1, idea 2.) - Putting decision facts in
subject/resource. Ordinance matches on context only. Attributes insubject/resourceare recorded as evidence but never read — so a fact placed there reads as absent and (for arequireskey) escalates. Ordinance will hint when it detects this, but design for it: decision facts go incontext. - Trusting a typo.
require:/equal:silently no-op. Always runapp.validate. - Trying to express what the operators can't. There are no numeric / range / regex operators — you cannot write "block exports over 100 records" directly (a
recordsvalue is simply ignored by a rule that doesn't name it). Precompute the judgment in a trusted source and pass a boolean:{ key: over_row_limit, equals: false }. - Un-gated tool paths. A tool the agent can reach without going through
enforce()is ungoverned. Coverage = discipline of integration. - Self-approving escalations. An ESCALATE is only a real human-in-the-loop control if the party that triggered it can't also approve it. Don't let the agent (or the shared machine key) hold approval authority — route approvals to an authenticated human.
Part 5 — Safe-deployment checklist (tick before go-live)
- Trusted context — every decision-critical fact is set by a trusted source, not the agent. (Walk each
requireskey: where does its value actually come from?) - No un-gated path — the agent can reach each governed capability only through the enforcement gate. No raw shell, no direct client, no bypass function.
- Separated approvals — approving an escalation requires an authenticated human, distinct from the credential the agent / caller uses. The evaluating party can't self-approve.
- Fail-closed integration — a transport / timeout error from Ordinance is treated as block, never proceed;
OrdinanceErroris never caught into an allow path.default_decision: escalateis left on. - Protected policy & ledger — write access to the policy directory (authority to change the rules) and the database (the evidence ledger) is locked down and backed up. For high-assurance, signed evidence, pair with Attestor.
- Paired with containment for hostile agents — for the compromised / rogue-agent threat, Ordinance is the policy brain deployed behind network-egress and sandbox controls that make bypass physically impossible, not in place of them.
- Validated & adversarially tested —
app.validatepasses; every action survives the Step 7 battery (happy path, refusal, omission, trigger, the lie, the bypass).
Part 6 — Worked example, end to end
Capability: a support agent can export a customer dataset (potentially CUI).
Step 1 — action: data.export_cui.
Step 2 — trusted facts:
| Fact | Trusted source |
|---|---|
dlp_scan_passed | The DLP service's actual scan result on the payload |
destination_approved | Platform check against the approved-destination list |
destination_classification | Data-catalog classification of the target |
Note none of these is set by the agent — the agent requests the export; the platform establishes the facts and calls Ordinance with them.
Steps 3–4 — policy: the DATA-001 rule above, with default_decision: escalate.
Step 6 — enforcement: the export tool is only reachable via run_tool("export_cui", ...), which builds context from the platform's trusted facts (not the agent's arguments) and calls enforce() before the export runs.
Step 7 — adversarial results (what you should see):
| Test | Context | Outcome |
|---|---|---|
| Happy path | { dlp_scan_passed: true, destination_approved: true } | ALLOW |
| Refusal | { dlp_scan_passed: false, destination_approved: true } | DENY |
| Omission | { destination_approved: true } (DLP fact missing) | ESCALATE |
| High-risk | both true, destination_classification: "restricted" | ESCALATE |
| The lie | agent tries to set dlp_scan_passed: true itself | blocked by design — that fact only comes from the DLP service |
| The bypass | agent calls the raw export function | blocked by design — no un-gated path exists |
Step 8 — evidence: each of the above is now a logged decision citing NIST 800-171 3.1.3 / CMMC L2 AC.L2-3.1.3, exportable as the decision log + control matrix an assessor will ask for.
That's the whole loop: a capability an agent can use freely within policy, unable to exceed it, producing its own compliance evidence as it goes.
Where to go next
- Introspection endpoint (
/v1/policies/applicable) and the reference client live in the Ordinance build; wire them per Part 2, Step 6. - High-assurance evidence (signed, verifiable bundles) is Attestor's job — pair the two when an assessor needs cryptographic integrity, not just a complete ledger.
- Questions or a policy review? info@phaethonsecurity.com.
This framework reflects Ordinance reference build v3. The safe-deployment requirements (trusted context, no un-gated paths, separated approvals) are not optional niceties — they are what turns a decision API into a control an assessor will accept.
Prefer the PDF?
Same content, formatted for printing and internal circulation.