Back to Resources

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.

Ask for a policy review

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.

  1. It's a decision, not a wall. Ordinance's /v1/evaluate answers 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.
  2. The engine only knows what you tell it — and it believes you. Decisions are computed from a context object you supply. If your caller says mfa_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.)
  3. 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 meansTrusted source (not the agent)
dlp_scan_passedThe payload cleared DLPYour DLP engine's real result
destination_approvedTarget is on the allowlistYour platform's allowlist check
mfa_verifiedThe initiating human passed MFAYour 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:

OperatorSatisfied whenUse for
equals: <v>value equals v exactly (type-exact: true ≠ "true")boolean / known-value checks
in: [<v>, …]value is one of the listed valuesallowlists (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.validate

This 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 ALLOW

The 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_if fires → 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) + README

The 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 requires key, 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 of equals rules.
  • Presence-only for "a human is on record." { key: reviewer } requires some reviewer without constraining who — useful for accountability without over-modeling.
  • escalate_if for "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 controls to 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 in subject / resource are recorded as evidence but never read — so a fact placed there reads as absent and (for a requires key) escalates. Ordinance will hint when it detects this, but design for it: decision facts go in context.
  • Trusting a typo. require: / equal: silently no-op. Always run app.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 records value 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)

  1. Trusted context — every decision-critical fact is set by a trusted source, not the agent. (Walk each requires key: where does its value actually come from?)
  2. 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.
  3. Separated approvals — approving an escalation requires an authenticated human, distinct from the credential the agent / caller uses. The evaluating party can't self-approve.
  4. Fail-closed integration — a transport / timeout error from Ordinance is treated as block, never proceed; OrdinanceError is never caught into an allow path. default_decision: escalate is left on.
  5. 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.
  6. 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.
  7. Validated & adversarially tested app.validate passes; 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:

FactTrusted source
dlp_scan_passedThe DLP service's actual scan result on the payload
destination_approvedPlatform check against the approved-destination list
destination_classificationData-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):

TestContextOutcome
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-riskboth true, destination_classification: "restricted"ESCALATE
The lieagent tries to set dlp_scan_passed: true itselfblocked by design — that fact only comes from the DLP service
The bypassagent calls the raw export functionblocked 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.