Policy enforcement runtime for AI and agentic applications.
Agentic systems (LangGraph, CrewAI, custom orchestrators, copilots with tools) do not just generate text — they invoke code that changes the world: approve loans, place trades, update records, send messages. When something goes wrong, it is rarely because the model “didn’t know” the rule. It is because the rule was never bound to execution.
1. Valid tool calls, invalid business outcomes
An agent calls approve_loan(approved_amount=7500, approval_mode="auto"). The
function runs, the side effect happens, and only later does someone notice the
business rule (“no auto-approval above $5,000”) was violated. The LLM had the right
intent in the prompt; the runtime had no gate on the tool boundary.
2. Shared tools, different roles — no consistent access control
loan-agent, compliance-agent, and a retail copilot may all call the same
approve_loan or place_trade_order function. Without a single enforcement layer,
every framework path reimplements if agent_id != ... (or forgets to). One code path
blocks; another bypasses the wrapper and calls the underlying callable directly.
3. Compliant tools, non-compliant user-facing text
Tool usage can be perfect while the final answer says “guaranteed approval” or “guaranteed returns.” Regulated domains (lending, healthcare, investments) care about what reaches the user, not only which APIs were called. Prompt instructions alone do not reliably prevent that.
4. Rules live in documents, not in runtime
Policies are written in Confluence, compliance playbooks, or English paragraphs in a
UI — but engineers ship scattered if checks (or nothing at all). Updates require
code deploys; auditors cannot replay “which policy fired on this call?” from a single
trace. Typos in field names can fail open (policy never matches → default allow).
5. Framework churn hides the real boundary
Teams switch orchestrators or add MCP servers and remote tools. Guardrails tied to one framework’s callback shape do not transfer. The durable boundary is simpler: the decorated tool call in-process, the agent identity for that turn, and the text you return to the user — but most stacks do not standardize those three.
- Duplication: the same threshold, allowlist, and workflow flag copied into every node, service, and example script.
- Drift: production allows what the demo blocked because one path never got the
new
if. - No compile-time safety: English policy text is not validated against inventory (unknown tools, misspelled fields) until something fails silently at runtime.
- Weak audit story: logs show “tool failed” without structured policy id, matched conditions, and decision type.
openagentpolicy is a policy enforcement runtime that sits on those boundaries.
Rules are declared as inventory + policies (YAML or HTTP), optionally authored in
English and compiled to structured form. At runtime, only compiled structured
policies are enforced — deterministically, with traces and audit events.
You add a small amount of integration code; governance owns the policy files.
| Boundary | Integration | What it enforces |
|---|---|---|
| Tool | @policy_tool on functions |
before_tool_call (can block or modify args before side effects), after_tool_call (redact/warn/log — cannot undo side effects) |
| Agent | agent_session(agent_id, metadata=...) around a turn or graph node |
Who is acting; workflow flags (e.g. human_reviewed, compliance_cleared) |
| Output | check_final_response(...) before returning text |
before_final_response (e.g. forbidden phrases) |
See Event semantics for what each trigger can and cannot do.
Compliance and product teams write rules in natural language (or structured YAML). The compiler:
- Resolves terms against inventory (tools, arguments, agents, aliases).
- Produces a structured policy or a clear
not_enforceable/needs_reviewresult. - In AI/hybrid mode, requires deterministic corroboration before auto-activation.
Production runtimes should load compiled policies only (policies.require_compiled: true), with English compilation done in authoring/UI — not on every request.
Costly automation mistake — A before_tool_call policy blocks
approved_amount > 5000 when approval_mode is auto. The tool never runs;
PolicyViolation forces an escalation path.
Shared tools, role-specific control — One policy: only loan-agent may call
approve_loan (or only trading-desk-agent may call place_trade_order). Every
orchestrator path that uses @policy_tool + agent_session gets the same decision.
Non-compliant final text — before_final_response blocks phrases like
“guaranteed approval” before the user sees them (literal contains / regex — not
semantic classification; see Scope and limitations).
Rules in runtime, not only in docs — Policies are versioned files or API payloads; reload without rewriting agent logic. Decisions land in traces/audit logs with matched policy ids.
Framework-agnostic at the boundary — LangGraph, a custom loop, or a CLI can all
call the same decorated tools inside agent_session. What matters is that tools are
not bypassed.
from openagentpolicy import configure, policy_tool, agent_session, check_final_response
configure("openagentpolicy.yaml") # once at startup
@policy_tool(id="approve_loan", risk_level="high", side_effect=True)
def approve_loan(application_id: str, approved_amount: float):
...
with agent_session("loan-agent", metadata={"human_reviewed": True}):
approve_loan(...)
safe = check_final_response(draft_reply, agent_id="loan-agent")Policies themselves live in policies/*.yaml — not as Python branches.
- One place for guardrails instead of N copies in graph nodes and microservices.
- Faster policy change — edit YAML, reload; optional HTTP policy provider.
- Auditability — structured decisions in traces (tool, agent, action, message).
- Safer multi-agent scale — agent id and session metadata drive allowlists and workflow gates without per-agent code forks.
- examples/loan_approval_basic — minimal tool-only sample.
- examples/agentic_loan_desk — English compilation, tool + agent + response policies.
- examples/agentic_stock_research — LangGraph workflow with English trade/research policies.
- examples/agentic_procurement — procurement / payables (POs, payments, budget metadata, English policies).
- examples/agentic_insurance_claims — insurance claims (CrewAI crew, intake/payout roles, supervisor metadata, English policies).
Tool enforcement runs at two points, and they have different capabilities:
| Event | Runs | Can prevent side effects? | Allowed actions |
|---|---|---|---|
before_tool_call |
before the tool executes | Yes | block, modify_args, warn, log_only, allow |
after_tool_call |
after the tool has returned | No | redact_result, warn, log_only, escalate |
Important: by the time after_tool_call runs, the tool has already executed
and any side effects (writes, payments, emails) have already happened. An
after_tool_call policy therefore cannot block, modify args, or redirect the
call — those decisions are ignored (with a warning in logs).
What after_tool_call can do:
redact_result— actually redacts the value returned to the caller using the configured privacy redactor (key/schema rules + optional PII scanning). This sanitizes what flows downstream, but does not undo the side effect.warn/log_only— record the condition.escalate— emit an escalation signal for out-of-band review.
If you need to prevent an action (the Example A failure mode — "don't approve
above 5000 in auto mode"), the policy must trigger on before_tool_call. Put
side-effecting work behind a before_tool_call guard; use after_tool_call
only to sanitize or flag results you cannot stop.
openagentpolicy now separates policy authoring from runtime enforcement.
-
Authoring / compilation mode
- Input can be English policy text.
- Compiler resolves terms against inventory.
- Compiler returns either:
- compiled structured/compiled policy object, or
- clear
not_enforceable/needs_reviewresult with errors.
- Only compiled structured policy should be saved/activated.
-
Runtime enforcement mode
- Runtime enforces deterministic structured/compiled policies only.
- English is an authoring format, not runtime enforcement format.
- In production, recommended config:
compiler.enabled: falsepolicies.require_compiled: trueenforcement.fail_on_unresolved_fields: trueenforcement.fail_on_unenforceable: true
English policy from UI/API -> compile against inventory -> validate enforceability -> generate deterministic compiled policy -> review/approve -> save compiled policy -> runtime enforces compiled policy only
pip install -e ".[dev]"from openagentpolicy import configure, policy_tool
configure("openagentpolicy.yaml")
@policy_tool(id="approve_loan", risk_level="high", side_effect=True)
def approve_loan(application_id: str, approved_amount: float):
return {"status": "approved", "application_id": application_id}The examples/agentic_loan_desk example shows English policies compiled and
enforced at both tool and agent scope.
- Install the package from the repo root:
pip install -e ".[dev]"- Run the scripted demo (no LLM required — uses the rule-based compiler):
cd examples/agentic_loan_desk
python demo.pyYou'll see five phases: English-policy compilation, tool threshold enforcement
($4k allowed / $7k blocked), agent allowlist (compliance-agent blocked),
the human-review workflow gate, and the final-response guard. An audit trail is
written to openagentpolicy_traces/events.jsonl.
- (Optional) Run the same flow through the AI compiler:
pip install openai
export OPENAI_API_KEY=sk-...
cd examples/agentic_loan_desk
python ai_demo.pyThis uses openagentpolicy.ai.yaml (english_compiler: ai). The LLM runs only
at load time, and its output is enforced only when the rule-based compiler
independently agrees (corroboration). Without a key the English policies show as
needs_review and are not enforced; ai_demo.py prints which policies loaded.
- (Optional) Inspect compilation without running scenarios:
cd examples/agentic_loan_desk
openagentpolicy compile-policy policies/01_tool_high_value_english.yaml --inventory inventory.yaml
openagentpolicy explain --config openagentpolicy.yaml --format jsonRun the scripts from inside
examples/agentic_loan_desk/: the configs use relative paths resolved against the config file, and the scripts import the localtools.py/agent_session.py. For a smaller, tool-only starting point seeexamples/loan_approval_basic/.
openagentpolicy is framework-agnostic. You keep your existing framework and add policy hooks at boundaries:
- Tool boundary: decorate callable tools with
@policy_tool. - Agent boundary: wrap a run/turn with
agent_session(agent_id, metadata=...). - Output boundary: call
check_final_response(...)orcheck_final_response_any(...)before returning user-visible text.
from openagentpolicy import agent_session, check_final_response
def run_turn(agent_id: str, session_meta: dict, messages: list[str]) -> str:
with agent_session(agent_id, metadata=session_meta):
draft = agent_generate_reply(messages)
return check_final_response(draft)from openagentpolicy import agent_session, check_final_response_any
def invoke_graph(graph, state: dict) -> dict:
with agent_session(state["agent_id"], metadata=state.get("metadata", {})):
out = graph.invoke(state)
out["final_message"] = check_final_response_any(out["final_message"])
return outfrom openagentpolicy import agent_session
def run_agent(agent, task: str, metadata: dict) -> str:
with agent_session(agent.id, metadata=metadata):
return agent.run(task) # tools called inside remain policy-enforcedRead these before relying on openagentpolicy as a control. They are deliberate
design boundaries, not bugs.
Policies only fire when a tool is invoked through the decorated function in the same process. The "framework-agnostic" claim holds at those boundaries and no further:
- If a framework, planner, or scheduler invokes the underlying callable
directly (bypassing the
@policy_toolwrapper), it is invisible to enforcement. - Remote/out-of-band tools (HTTP services, MCP servers, other processes) are not intercepted unless the call into them is itself wrapped.
- The output guard only sees text you explicitly pass to
check_final_response(...)/check_final_response_any(...).
Treat the decorated callable and the explicit output check as the trust boundary. Anything reaching a tool by another path is unenforced.
Final-response policies match on the response text using contains (case-
insensitive by default) and regex operators. This is literal string matching:
- It catches
"guaranteed approval"but not"approval is guaranteed","we guarantee you'll be approved", or other paraphrases. - It is not a semantic classifier and does not understand intent or meaning.
Use it for known forbidden phrases and patterns. Do not mistake it for an output-safety/LLM-judge layer; pair it with one if you need semantic coverage.
With default_action: allow (the default) plus fail-open field resolution, the
system's safety stance is allow by default, block only on an explicit match.
A policy that never matches (or whose tool is never wrapped) results in the
action being permitted.
For regulated deployments (BFSI, healthcare), this default is a deliberate
decision to revisit. Many such buyers will want default_action: block as
the baseline — at minimum for side_effect: true, high-risk tools — so that the
posture becomes "deny unless explicitly allowed." Load-time validation already
hard-fails on unresolved fields when default_action: allow (see
Unresolved field safety), but
that only protects against typos, not against unwrapped or out-of-band calls.
application:
id: my_app
environment: production
runtime:
default_agent_id: loan-agent
inventory:
provider: file
path: ./inventory.yaml
policies:
provider: directory
path: ./policies
support_english: true
compile_on_startup: true
traces:
enabled: true
store:
provider: local
path: ./openagentpolicy_traces
enforcement:
default_action: allow
on_policy_error: allow_with_warning
audit_enabled: true
fail_on_unresolved_fields: false # see note below
privacy:
redact_keys: [password, token, secret, pan, aadhaar, ssn]
pii_detection:
enabled: false # opt-in; off by default
engine: regex # regex | presidio
languages: [en]
entities: [CREDIT_CARD, EMAIL_ADDRESS, PHONE_NUMBER, IN_PAN, IN_AADHAAR, US_SSN]
scan_fields: [final_response, tool_result, tool_args]Every policy condition field is validated against the event context and
inventory at load time. A field that does not resolve (a typo like
tool_args.amount instead of tool_args.approved_amount, or a field not
available for the trigger event) is handled as follows:
fail_on_unresolved_fields: true— startup fails on any unresolved field.fail_on_unresolved_fields: false(default):- If
default_action: allow(fail open), unresolved condition fields are still rejected at startup. A misspelled field would otherwise silently never match and allow the guarded action — the worst outcome for a governance tool, so this is treated as a hard error. - If
default_action: block(fail closed), unresolved fields are logged as warnings, because a non-matching policy still blocks.
- If
Action-shape problems (e.g. a block action on after_tool_call, which the
runtime cannot honor) only fail startup under fail_on_unresolved_fields: true,
since they do not fail open.
Redaction runs in two complementary layers before anything is written to audit logs or traces:
-
Key/schema redaction (always on, deterministic).
- Any field whose name matches
privacy.redact_keysis masked. - Any tool argument flagged
sensitive: truein inventory is masked. - This layer never inspects values, so it is fast and predictable.
- Any field whose name matches
-
Value-level PII detection (optional, opt-in).
- Scans free-text values (e.g.
final_response, string args/results) for PII that is not captured by key names. - Controlled by
privacy.pii_detection.
- Scans free-text values (e.g.
engine: regex(default when enabled): deterministic, local, dependency-free. Uses regex plus checksum validation — Luhn for credit cards, Verhoeff for Aadhaar — to keep false positives low. Recommended for production because it is reproducible and needs no model calls.engine: presidio: value-level NER + recognizers via Microsoft Presidio. Best when you need broad free-text PII (names, addresses, locations). Requires the optional dependency:
pip install openagentpolicy[pii]Presidio is imported lazily, so the core runtime stays dependency-free. Because
its NER recognizers are model-dependent, prefer regex when you only need
structured identifiers (PAN, Aadhaar, SSN, cards).
scan_fields controls which event fields are scanned for PII values. Key/schema
redaction always applies regardless of scan_fields.
from openagentpolicy.pii import RegexPiiDetector, redact_spans
detector = RegexPiiDetector.from_entities(["EMAIL_ADDRESS", "IN_PAN"])
text = "Mail john@example.com, PAN ABCDE1234F"
spans = detector.detect(text)
safe = redact_spans(text, spans)application:
id: loan-approval-app
name: Loan Approval App
environment: demo
agents:
- id: loan-agent
name: Loan Agent
- id: compliance-agent
name: Compliance Agent
tools:
- id: approve_loan
name: Approve Loan
risk_level: high
side_effect: true
arguments:
approved_amount:
type: number
aliases: [approved amount]
approval_mode:
type: string
allowed_values: [auto, manual]
- id: send_to_human_review
name: Send to Human Review
risk_level: medium
side_effect: true
arguments:
reason:
type: stringinventory:
provider: http
url: https://policy-service.example.com/v1/inventoryExpected API response shape (JSON or YAML body):
{
"agents": [{"id": "loan-agent"}],
"tools": [
{
"id": "approve_loan",
"arguments": {
"approved_amount": {"type": "number"}
}
}
]
}policies:
provider: directory
path: ./policiespolicies:
provider: file
path: ./policies/auto_approval.yamlpolicies:
provider: http
url: https://policy-service.example.com/v1/policies
support_english: trueExpected API response can be one policy object or an array of policy objects (same schema as local policy YAML files).
To refresh policies after an API-side update:
from openagentpolicy import get_runtime
get_runtime().reload()English policies can be compiled using three strategies:
hybrid(default; recommended for free-form authoring): AI first, then deterministic fallback. Use this when authors write policies in natural language. If the AI translator is unavailable (no API key/SDK/response), it degrades gracefully to the rule-based result — no network dependency is required for startup.rule_based(locked template grammar): deterministic matching of a small, fixed set of sentence shapes against inventory. It does not attempt to parse arbitrary English — anything outside the documented grammar is reported asnot_enforceable/needs_reviewrather than guessed. Choose this when you want a fixed, auditable grammar with no model calls.ai: LLM conversion to structured policy, still schema/inventory validated and corroborated by the rule-based compiler before activation.
The rule-based grammar is intentionally small and is not meant to grow to
cover natural English. If you find yourself wanting another sentence pattern,
prefer hybrid rather than expanding the regex set. The exact supported grammar
is documented below under "Rule-based grammar (locked templates)".
policies:
provider: directory
path: ./policies
support_english: true
english_compiler: hybrid # default; one of: hybrid | rule_based | ai
ai:
model: gpt-4.1-mini
api_key_env: OPENAI_API_KEY
base_url: null # optional custom endpointNote: there is no confidence threshold. A model's self-reported confidence is not a calibrated probability, so it is not used as a safety gate. AI output is auto-
compiledonly when the deterministic rule-based compiler independently produces an equivalent structured policy (see Safety model below).
-
rule_based- Uses deterministic parsing from
openagentpolicy/policies/compiler.py - Best for controlled policy templates and strict reproducibility
- Supports only the fixed grammar documented below; out-of-grammar text is not guessed
- Uses deterministic parsing from
-
ai- Uses an LLM translator to generate structured policy JSON
- Validates generated policy with schema + inventory alignment checks
- Corroborates against the deterministic rule-based compiler
- Returns:
compiledwhen valid, inventory-aligned, AND the deterministic compiler independently produces an equivalent structured policyneeds_reviewwhen output is uncorroborated (the deterministic compiler disagrees or cannot independently compile the rule), or shape is invalidnot_enforceablewhen policy references unknown tools/fields
-
hybrid- Tries AI first
- If AI does not produce
compiled, runs the rule-based compiler - Uses deterministic output when the fallback succeeds
- Surfaces a genuine AI candidate (schema-valid, inventory-aligned, but
uncorroborated) as
needs_review; if there is no AI signal at all (e.g. the translator is unavailable), it defers to the deterministic result instead of masking it with a generic review status
- Install OpenAI SDK in your environment (
pip install openai) - Set API key in env variable configured by
policies.ai.api_key_env(defaultOPENAI_API_KEY) - Keep
support_english: true
The translator constrains the model with the OpenAI Responses structured-output
(json_schema) format so it must emit the exact Policy shape — valid operator
symbols, leaf/group conditions — instead of free-form JSON. If the SDK/endpoint
rejects that argument, it falls back to an unconstrained call (the prompt also
describes the schema). The compiled policy's id/name are always pinned to the
source document, not the model's output, so enforced policies stay traceable.
If AI is unavailable (no key/dependency/response), AI mode returns needs_review; hybrid mode will attempt deterministic fallback automatically.
Even in AI mode, generated policy is never enforced blindly:
- Parse model output as JSON
- Validate against
Policyschema - Validate
tool_idand condition field paths against inventory - Require deterministic corroboration: the rule-based compiler must independently produce a semantically equivalent structured policy
- Enforce only
compiledpolicies
The trust signal is agreement between an LLM and an independent deterministic
compiler — not a self-reported confidence score. Uncorroborated AI output is
surfaced as needs_review for a human to approve, never auto-activated. This
keeps natural-language flexibility while preserving enforcement safety.
The rule-based compiler recognizes only the following sentence shapes. A leading
if is optional for the comparison templates. Terms in <...> are resolved
against inventory (argument names, aliases, descriptions, and allowed_values);
unresolved terms make the policy not_enforceable.
| English template | Compiles to |
|---|---|
<field> is greater than|more than|above <number> |
field > number |
<field> is less than|below <number> |
field < number |
<field> is equal to <number> |
field == number |
if <field> is missing |
field not_exists |
value phrase from allowed_values (e.g. auto approval) |
field == "<value>" |
only <agent> may call|use <tool> |
agent_id != <agent> |
Action is taken from the policy's consequent clause only:
- block intent —
not allowed,not permitted,may not,must not,should not,cannot,can't→action: block warn→action: warn- otherwise supply an explicit
hints.action; without one the action is undetermined and the policy isnot_enforceable(it will not silently block).
Anything not matching the above is out of grammar. Do not extend this set to
chase natural English — switch the policy (or the runtime) to hybrid instead.
The authoritative definition lives in the module docstring of
openagentpolicy/policies/compiler.py.
id: block_large_auto
enabled: true
policy_type: structured
trigger:
event: before_tool_call
tool_id: approve_loan
conditions:
all:
- field: tool_args.approved_amount
operator: ">"
value: 5000
- field: tool_args.approval_mode
operator: "=="
value: auto
action:
type: block
message: Auto approval is not allowed above 5000.id: block_large_auto_english
enabled: true
policy_type: english
english: >
If approved amount is greater than 5000 and approval mode is auto,
auto approval is not allowed.
hints:
action:
type: block
message: Auto approval is not allowed above 5000.id: agent_allowlist_for_approve
enabled: true
policy_type: structured
trigger:
event: before_tool_call
tool_id: approve_loan
conditions:
field: agent_id
operator: "!="
value: loan-agent
action:
type: block
message: Only loan-agent may approve loans.id: require_human_review_for_large_manual
enabled: true
policy_type: structured
trigger:
event: before_tool_call
tool_id: approve_loan
conditions:
all:
- field: tool_args.approved_amount
operator: ">"
value: 5000
- field: metadata.human_reviewed
operator: "!="
value: true
action:
type: block
message: Human review required before large approval.id: block_guaranteed_claims
enabled: true
policy_type: structured
trigger:
event: before_final_response
conditions:
field: final_response
operator: contains
value: guaranteed approval
action:
type: block
message: Do not promise guaranteed approval.Use the UI/backend-facing compile API:
from openagentpolicy.compiler import compile_english_policy
result = compile_english_policy(
english="If approved amount is greater than 5000, auto approval is not allowed.",
inventory=inventory,
policy_id="policy_auto_approval_threshold",
action_hint={"type": "block", "message": "Auto approval is not allowed above 5000."},
)
if result.status.value == "compiled":
compiled_policy = result.compiled_policy
# persist compiled policy
else:
# show result.errors / result.missing_terms / result.suggested_fixes
passCompiled policy shape example:
id: policy_auto_approval_threshold
policy_type: compiled
trigger:
event: before_tool_call
tool_id: approve_loan
conditions:
all:
- field: tool_args.approved_amount
operator: ">"
value: 5000
- field: tool_args.approval_mode
operator: "=="
value: auto
action:
type: block
source:
english: If approved amount is greater than 5000, auto approval is not allowed.
compiler_version: "1.0"
validation:
enforceability: enforceableNot-enforceable example:
English:
If KYC is not verified, do not approve the loan.
If kyc_status is not in inventory, compile returns not_enforceable with missing terms and actionable fixes.
All commands use the openagentpolicy entrypoint (stdlib argparse, no extra CLI dependency).
openagentpolicy validate-config openagentpolicy.yamlopenagentpolicy validate-inventory inventory.yamlopenagentpolicy validate-policy policies/auto_approval_structured.yaml --inventory inventory.yamlopenagentpolicy compile-policy policies/english.yaml --inventory inventory.yaml
openagentpolicy compile-policy policies/ --inventory inventory.yamlCompile one English policy string to deterministic compiled policy:
openagentpolicy compile-english \
--inventory inventory.yaml \
--policy-id policy_auto_approval_threshold \
--english "If approved amount is greater than 5000, auto approval is not allowed." \
--output compiled_policy.yamlExplain one policy against inventory:
openagentpolicy explain-policy \
--inventory inventory.yaml \
--policy policies/policy.yamlExit code 0 means allow. Exit code 1 means block or restrictive decision.
openagentpolicy test-policy policies/auto_approval_structured.yaml \
--event events/high_value_loan.json \
--inventory inventory.yamlEvent file format:
{
"event_type": "before_tool_call",
"tool_id": "approve_loan",
"tool_args": {
"approved_amount": 50000,
"approval_mode": "auto"
},
"agent_id": "loan-agent",
"metadata": {
"human_reviewed": false
}
}openagentpolicy generate-inventory \
--traces ./openagentpolicy_traces/events.jsonl \
--output generated_inventory.yamlRun a single tool-call decision against the configured runtime, without wrapping any code:
openagentpolicy check --config openagentpolicy.yaml --tool approve_loan \
--args '{"approved_amount": 5000, "approval_mode": "auto"}'pip install -e ".[dev]"
pytestApache-2.0