Agent Policy Layer - Portable, composable policies for AI agents
Project description
APL🛡️
Agent Policy Layer
Portable, composable, fail-closed guardrails for AI agents.
Like MCP — but for constraints instead of capabilities.
APL restrains your agents — when you need him to! 🚔
The agent drafts a reply containing PII. APL intercepts at
output.pre_send, the policies vote, and the message that
actually goes out is redacted — without changing a line of the agent.
Table of contents
- Why APL?
- How it works
- Install
- Quick start
- Writing policies
- Connect it to your agent
- Composing multiple policies
- Fail-closed by design
- Reference
- Examples
- Contributing
Why APL?
Agents are increasingly trusted with real actions — sending email, calling tools, touching production data. The same traits that make them useful (open-ended reasoning, tool use) make them hard to constrain, and a system prompt that says "never reveal PII" is a suggestion, not a guarantee.
APL is a small protocol for wrapping an agent in enforceable policies, independent of the framework it's built on. A policy observes a moment in the agent's lifecycle — a user message, an LLM request, a tool call, an outgoing response — and returns a verdict:
| Verdict | Meaning |
|---|---|
allow |
proceed unchanged |
deny |
block the action |
modify |
rewrite the payload (redact, replace, append, patch) and continue |
escalate |
hand off to a human (confirm / review) |
observe |
record it; don't block |
Policies live in policy servers your agent connects to over stdio or HTTP. The same policy works whether your agent is raw OpenAI calls, a LangGraph graph, or something bespoke — and you can update a policy without redeploying the agent.
What APL is not: it isn't a model or a classifier. You bring the detection logic — a regex, an LLM call, a classifier, a lookup. APL is the lifecycle hooks, the verdict vocabulary, the composition of many policies into one decision, and the enforcement around them. The design borrows MCP's shape: where MCP gives a model capabilities through a uniform protocol, APL applies constraints through one.
What that buys you:
- Runtime-agnostic — one policy, any agent stack.
- Rich verdicts —
modify/escalate/observe, not just allow/deny. - Declarative or code — author in Python, or in YAML with no Python at all.
- Composable — run many policy sources at once with defined merge semantics.
- Fail-closed — if a policy can't be reached or times out, the action is denied by default. Fail-open is an explicit, logged opt-in. (details)
- One-line auto-instrumentation — patch OpenAI / Anthropic / LiteLLM / LangChain / watsonx in place.
How it works
One event fires in your agent. APL builds a PolicyEvent, fans it out to every connected policy server, collects the verdicts, composes them into a single decision, and enforces it. If any server is unreachable, the composition fails closed.
flowchart TB
subgraph agent["Your agent · any runtime"]
direction LR
E1["input.received"]:::evt
E2["llm.pre_request"]:::evt
E3["tool.pre_invoke"]:::evt
E4["output.pre_send"]:::evt
end
agent -->|"PolicyEvent<br/><small>messages · payload · metadata</small>"| layer
subgraph layer["APL Policy Layer · in-process"]
compose["VerdictComposer<br/><small>deny_overrides · unanimous · weighted · …</small>"]:::core
end
layer -->|stdio| p1
layer -->|stdio| p2
layer -->|"http(s)"| p3
subgraph servers["Policy servers · portable, hot-swappable"]
direction LR
p1["Python<br/><small>@server.policy</small>"]:::srv
p2["YAML<br/><small>declarative rules</small>"]:::srv
p3["Remote<br/><small>shared org policies</small>"]:::srv
end
p1 & p2 & p3 -->|"Verdict(s)"| compose
compose --> enforce{{"Composed verdict"}}:::core
enforce -->|allow| r1["proceed"]
enforce -->|modify| r2["redact / rewrite payload"]
enforce -->|deny| r3["block · PolicyDenied"]
enforce -->|escalate| r4["human confirm / review"]
enforce -->|observe| r5["log only"]
servers -.->|"unreachable · timeout · error"| fc["fail-closed → deny<br/><small>(fail-open is an explicit opt-in)</small>"]:::warn
classDef evt fill:#0e1a2b,stroke:#3fd8ff,stroke-width:1px,color:#cfe9ff;
classDef core fill:#07212b,stroke:#3fd8ff,stroke-width:1.5px,color:#e8faff;
classDef srv fill:#19130a,stroke:#fbbf24,stroke-width:1px,color:#fde9c0;
classDef warn fill:#1e0f12,stroke:#fb7185,stroke-width:1px,color:#ffd9df;
Lifecycle events
A policy subscribes to one or more events and only receives those.
| Event | Fires | Typical use |
|---|---|---|
input.received |
a user message arrives | prompt-injection / input validation |
llm.pre_request |
before calling the model | budget & cost limits, prompt rewriting |
llm.post_response |
after the model replies | hallucination / grounding checks |
tool.pre_invoke |
before a tool runs | permission checks, confirm destructive actions |
tool.post_invoke |
after a tool returns | result validation |
output.pre_send |
before the reply reaches the user | PII redaction, content filtering |
Full event list
input.received · input.validated · plan.proposed · plan.approved · llm.pre_request · llm.post_response · tool.pre_invoke · tool.post_invoke · agent.pre_handoff · agent.post_handoff · output.pre_send · session.start · session.end
Install
pip install agent-policy-layer
Pure Python, no external services. Requires Python 3.10+. For the LangGraph adapter: pip install "agent-policy-layer[langgraph]".
Quick start
1 · Write a policy — guard.py:
import re
from apl import PolicyServer, Verdict
server = PolicyServer("guard")
@server.policy(name="redact-ssn", events=["output.pre_send"])
async def redact_ssn(event):
text = event.payload.output_text or ""
if re.search(r"\d{3}-\d{2}-\d{4}", text):
return Verdict.modify(
target="output",
operation="replace",
value=re.sub(r"\d{3}-\d{2}-\d{4}", "[SSN REDACTED]", text),
reasoning="Redacted SSN",
)
return Verdict.allow()
if __name__ == "__main__":
server.run() # stdio by default; `server.run(transport="http", port=8080)` for HTTP
2 · Try it — no agent required:
apl test ./guard.py -e output.pre_send -p '{"output_text": "Your SSN is 123-45-6789"}'
# → MODIFY · output → "Your SSN is [SSN REDACTED]" · Redacted SSN
3 · Put it in front of your LLM — one line, and output.pre_send runs on every reply:
import apl
apl.auto_instrument(policy_servers=["stdio://./guard.py"])
from openai import OpenAI
resp = OpenAI().chat.completions.create(model="gpt-4o", messages=[...])
print(resp.choices[0].message.content) # already redacted if a policy fired
That's the whole loop: author a verdict, see it in isolation, then enforce it live.
Writing policies
A policy is a function that takes a PolicyEvent and returns a Verdict. The five verdict constructors:
Verdict.allow()
Verdict.deny(reasoning="contains prohibited content")
Verdict.modify(target="output", operation="replace", value="[REDACTED]", reasoning="PII detected")
Verdict.escalate(type="human_confirm", prompt="Delete production database?", options=["Proceed", "Cancel"])
Verdict.observe(reasoning="logged for audit", trace={"action": "sensitive_query"})
modify carries a target and an operation, both enforced end-to-end:
target |
operation |
|---|---|
input · llm_prompt · tool_args · tool_result · output · plan · handoff_payload |
replace · redact · append · prepend · patch (with a JSON path) |
In Python
@server.policy registers a handler. Declare the events it subscribes to, and optionally the context paths it needs:
from apl import PolicyServer, Verdict
server = PolicyServer("safety", description="Confirm destructive tool calls")
@server.policy(
name="confirm-delete",
events=["tool.pre_invoke"],
context=["payload.tool_name", "payload.tool_args"],
)
async def confirm_delete(event):
tool = (event.payload.tool_name or "").lower()
if any(word in tool for word in ("delete", "drop", "destroy")):
return Verdict.escalate(
type="human_confirm",
prompt=f"⚠️ Destructive action: {event.payload.tool_name}. Proceed?",
options=["Proceed", "Cancel"],
)
return Verdict.allow()
In YAML — no Python required
The same kinds of rules, declaratively. Serve a .yaml file exactly like a .py one.
# compliance.yaml
name: corporate-compliance
version: 1.0.0
policies:
- name: block-competitor-info
events: [output.pre_send]
rules:
- when:
payload.output_text: { contains: "competitor financials" }
then:
decision: deny
reasoning: "Cannot share competitor financial information"
- name: confirm-eu-export
events: [tool.pre_invoke]
rules:
- when:
payload.tool_name: { matches: ".*export.*" }
metadata.user_region: { in: [EU, EEA, UK] }
then:
decision: escalate
escalation:
type: human_confirm
prompt: "🇪🇺 GDPR: confirm data export for an EU user?"
apl validate ./compliance.yaml # checks operators, modification & escalation shapes
apl serve ./compliance.yaml --http 8080
Supported condition operators: equals · matches (case-insensitive regex, matches anywhere) · contains · in · gt · gte · lt · lte · not · any · all. Unknown operators are rejected at load time rather than silently skipped.
Connect it to your agent
Four ways in, from least to most explicit.
Auto-instrumentation
Patches the SDKs you have installed — OpenAI, Anthropic, LiteLLM, LangChain, watsonx — so every call flows through your policies.
import apl
state = apl.auto_instrument(
policy_servers=["stdio://./guard.py", "https://policies.corp.com/compliance"],
user_id="user-123",
# enabled_providers=["openai"], # default: every installed provider
# fail_mode=apl.FailMode.OPEN, # default: CLOSED (see below)
)
# ... use your LLM SDK as normal ...
apl.uninstrument(state)
On each call, modify verdicts rewrite the request/response before your code sees it; deny and escalate raise PolicyDenied / PolicyEscalation. Prefer a scoped block? Use the context manager, which always restores the SDKs on exit:
with apl.instrument(policy_servers=["stdio://./guard.py"]):
...
Streamed responses are buffered so output policies can run on the full text before any chunk is delivered — a
denyactually stops the stream. That's the honest cost of enforcing guardrails on a stream.
Manual evaluation
Full control: build the event, read the verdict, decide what to do.
from apl import PolicyLayer, EventPayload, SessionMetadata
layer = PolicyLayer()
layer.add_server("stdio://./guard.py")
verdict = await layer.evaluate(
event_type="output.pre_send",
payload=EventPayload(output_text=response_text),
metadata=SessionMetadata(user_id="user-123"),
)
if verdict.decision.value == "modify":
response_text = verdict.modifications[0].value
elif verdict.decision.value == "deny":
raise RuntimeError(verdict.reasoning)
Decorator
Wrap a function so a verdict is enforced around each call — deny/escalate raise, modify rewrites the tool_args:
@layer.on("tool.pre_invoke")
async def call_tool(tool_name: str, tool_args: dict):
...
try:
await call_tool("delete_record", {"id": 42})
except apl.PolicyEscalation as e:
print(e.verdict.escalation.prompt)
LangGraph
wrap() instruments a StateGraph's nodes; it raises TypeError on anything that isn't a graph (never a silent no-op).
from apl import PolicyLayer
guarded = PolicyLayer().add_server("stdio://./guard.py").wrap(graph)
For custom checkpoints, use APLGraphWrapper and add_checkpoint(event_type, node_name=..., before=...) directly.
Composing multiple policies
When several policies (or servers) weigh in on one event, a composition strategy reduces their verdicts to one. Configure it on the layer:
from apl import PolicyLayer, CompositionConfig, CompositionMode
layer = PolicyLayer(composition=CompositionConfig(
mode=CompositionMode.DENY_OVERRIDES, # default
timeout_ms=500,
weights={"trusted-policy": 2.0}, # used by WEIGHTED
priority=["pii-filter"], # used by FIRST_APPLICABLE
))
| Mode | How verdicts combine |
|---|---|
deny_overrides (default) |
any deny wins; else escalate; else apply all modify; else allow |
allow_overrides |
any allow wins; else modify; else escalate; else deny |
unanimous |
every non-observe verdict must be allow, otherwise deny |
first_applicable |
first non-observe verdict wins, in priority order |
weighted |
confidence × per-policy weight vote; escalate short-circuits; deny breaks ties |
Fail-closed by design
APL is a guardrails layer, so its defaults assume that a guard you can't consult is a guard that says no.
- Unavailable → deny. If a policy times out, errors, returns a non-verdict, or its server is unreachable, the result is
deny(FailMode.CLOSED). Enforcement isn't silently skipped. - Fail-open is explicit and loud.
FailMode.OPENmust be passed deliberately (PolicyLayer(composition=CompositionConfig(fail_mode=FailMode.OPEN))orauto_instrument(..., fail_mode=FailMode.OPEN)), and it logs a warning on startup. - Layer timeout fails closed too.
CompositionConfig.timeout_msbounds the whole evaluation; on expiry it returnson_timeout(defaultdeny). - Protocol version is checked on connect. An incompatible server is treated as unavailable rather than trusted.
Safe-by-default also extends to the HTTP transport:
- binds
127.0.0.1(not0.0.0.0), - optional bearer-token auth (
--auth-token), constant-time compared, - CORS allow-list instead of
*— no CORS headers unless an origin is allow-listed, - request-size and content-type guards; malformed input returns a
4xxwith a stable error envelope, never an echoed traceback.
Reference
CLI
apl serve POLICY [--http PORT] [--host HOST] [--auth-token TOKEN]
[--cors-origin ORIGIN]... [--max-body BYTES] [-v|-q]
apl test POLICY [-e EVENT] [-p JSON_PAYLOAD]
apl validate POLICY
apl init NAME [-t basic|pii|budget|confirm]
apl info
POLICY is a .py file, a .yaml file, or a directory of them. serve uses stdio unless --http PORT is given.
Examples
apl serve ./guard.py # stdio (for stdio:// clients)
apl serve ./guard.py --http 8080 # HTTP on 127.0.0.1:8080
apl serve ./policies/ --http 8080 \ # serve a directory
--auth-token "$APL_TOKEN" --cors-origin https://app.corp.com
apl test ./guard.py -e tool.pre_invoke -p '{"tool_name": "delete_db"}'
apl init my-guard -t pii # scaffold a new policy project
apl info # version, protocol, transports, adapters
Python API
from apl import (
PolicyServer, PolicyLayer, PolicyClient, # core
Verdict, Decision, Modification, Escalation, # verdicts
EventType, PolicyEvent, EventPayload, # events
Message, ToolCall, FunctionCall, SessionMetadata, # context (chat/completions shape)
CompositionMode, CompositionConfig, VerdictComposer,
FailMode, # CLOSED (default) | OPEN
PolicyDenied, PolicyEscalation, PolicyUnavailableError,
auto_instrument, instrument, uninstrument, # SDK patching
load_yaml_policy, validate_yaml_policy, # declarative
APLGraphWrapper, # LangGraph
setup_logging, get_logger,
)
| Object | Key surface |
|---|---|
PolicyServer(name, version=…, description=…) |
@server.policy(name, events, context=…, blocking=True, timeout_ms=1000), server.run(transport="stdio"|"http", **kw) |
PolicyLayer(composition=…) |
.add_server(uri) → self, await .evaluate(event_type, messages=…, payload=…, metadata=…) → Verdict, .on(event), .wrap(graph), .fail_mode, await .close() |
Verdict |
.decision, .confidence, .reasoning, .modifications, .escalation + the five constructors |
Server URIs: stdio://./policy.py · http://host:port · https://host.
HTTP API
When a server runs with --http:
| Method | Endpoint | Purpose |
|---|---|---|
POST |
/evaluate |
evaluate a PolicyEvent, return per-policy + composed verdicts |
GET |
/manifest |
the server's policies and protocol version |
GET |
/health |
status, policies loaded, uptime |
GET |
/metrics |
Prometheus metrics |
GET |
/events |
Server-Sent Events stream |
Examples
Runnable policies in examples/:
| File | Shows |
|---|---|
| pii_filter.py | redact SSNs, cards, emails on output.pre_send; block PII in tool calls |
| budget_limiter.py | token & cost budgets from session metadata; observe warnings, deny over budget |
| confirm_destructive.py | escalate for destructive tools; role-aware checks |
| compliance.yaml | the same ideas, declaratively |
| usage_demo.py | manual evaluation, the decorator API, and composition |
apl serve examples/pii_filter.py --http 8080
Contributing
Issues and PRs welcome — see CONTRIBUTING.md, the security policy, and the code of conduct.
Licensed under Apache 2.0.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agent_policy_layer-0.5.0.tar.gz.
File metadata
- Download URL: agent_policy_layer-0.5.0.tar.gz
- Upload date:
- Size: 3.2 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e98e1ccaa35f2a94c2082983d4824ee03d99e5768a960909d0b54e70fe812ead
|
|
| MD5 |
f71bc534db7bf7a48b93e3a176ff434f
|
|
| BLAKE2b-256 |
a6f9f1c7bd890ddbd3fa38c4406a0c5d67d3fb3f2ce7cb10b6bdb0ab9935de44
|
Provenance
The following attestation bundles were made for agent_policy_layer-0.5.0.tar.gz:
Publisher:
release-please.yml on nimonkaranurag/agentpolicylayer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_policy_layer-0.5.0.tar.gz -
Subject digest:
e98e1ccaa35f2a94c2082983d4824ee03d99e5768a960909d0b54e70fe812ead - Sigstore transparency entry: 1809869682
- Sigstore integration time:
-
Permalink:
nimonkaranurag/agentpolicylayer@7e0fb6aac11555ad916019aa5e96701569625105 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/nimonkaranurag
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@7e0fb6aac11555ad916019aa5e96701569625105 -
Trigger Event:
push
-
Statement type:
File details
Details for the file agent_policy_layer-0.5.0-py3-none-any.whl.
File metadata
- Download URL: agent_policy_layer-0.5.0-py3-none-any.whl
- Upload date:
- Size: 137.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
82d048d18ead78f4f835d0398b7bb0b7cb8f141e6e72f0974522479fac0c70bc
|
|
| MD5 |
3c35364258f42df1e4afd5ba9a10946e
|
|
| BLAKE2b-256 |
c757e8243eccabd678cd6ef6ccc781b0ec0089c6cfe72283dd6c5881220c7075
|
Provenance
The following attestation bundles were made for agent_policy_layer-0.5.0-py3-none-any.whl:
Publisher:
release-please.yml on nimonkaranurag/agentpolicylayer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_policy_layer-0.5.0-py3-none-any.whl -
Subject digest:
82d048d18ead78f4f835d0398b7bb0b7cb8f141e6e72f0974522479fac0c70bc - Sigstore transparency entry: 1809869715
- Sigstore integration time:
-
Permalink:
nimonkaranurag/agentpolicylayer@7e0fb6aac11555ad916019aa5e96701569625105 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/nimonkaranurag
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@7e0fb6aac11555ad916019aa5e96701569625105 -
Trigger Event:
push
-
Statement type: