Write and test policies
Write MAQPNA policies with rules that allow, deny or require approval, add Cedar where you need it, and prove them with maqpna policy test, eval and replay before they reach the gateway.
flowchart LR
A[Write a policy<br/>ToolPolicy YAML or bundle JSON] --> B[maqpna policy lint]
B --> C[maqpna policy test<br/>single case or suite]
C --> D[maqpna replay<br/>against the audit ledger]
D --> E{Decisions as intended?}
E -- no --> A
E -- yes --> F[Load it: dev policies.json<br/>or maqpna apply]
F --> G[The gateway enforces it<br/>on every tool call]
Goal#
Write a policy that decides which tool calls an agent may make, test it offline, measure what it would have changed on real traffic, and load it into a local MAQPNA or a cluster.
A policy is a named set of rules. Each rule matches calls (server, tool, arguments, user, time,
taint) and gives an action: allow, deny or require_approval. The MAQPNA gateway evaluates every
tool call against every policy that applies and writes the decision (allowed, denied or held for
approval) to the audit ledger, with the policy and rule that made it.
Prerequisites#
- The
maqpnaCLI. Release builds include the Cedar adapter; see Cedar. - For steps 6 and 7: a local MAQPNA from Your first governed agent.
- For step 8: a cluster with MAQPNA installed (Production install).
How the gateway decides#
Read these rules once; every result below follows from them.
| Rule | What it means |
|---|---|
| A policy applies to a call | Its namespace glob matches the session's namespace (empty or * matches any) and one of its agents globs matches the agent (no agents means any agent). |
| First matching rule wins | Inside one policy, rules are checked in order. The first rule that matches gives the action. |
defaultAction |
When no rule in a policy matches, its defaultAction applies. Unset means deny. |
| Most restrictive wins | Across all policies that apply: deny beats require_approval, which beats allow. |
| Default deny | When no policy applies at all, the call is denied with reason no_applicable_policy. |
| Rate limits | A matched rule with maxCallsPerMinute is limited per session, policy and rule over a sliding 60 s window. Over the limit, the call is denied with reason rate_limited. |
A rule matches only when every condition it sets matches. The conditions are checked in this order:
servers, tools, argMatch, principals, scopes, sessionTaints, sinks, schedule, when.
Globs follow Go path.Match, except that * alone matches any value.
Steps#
1. Write a policy#
In a cluster a policy is a ToolPolicy resource. This is config/samples/toolpolicy-coder.yaml from the
repository:
apiVersion: maqpna.com/v1alpha1
kind: ToolPolicy
metadata:
name: coder-policy
namespace: team-a
spec:
agents: ["coder", "coder-*"]
defaultAction: deny
rules:
- name: github-read
servers: [github]
tools: ["get_*", "list_*", "search_*"]
action: allow
maxCallsPerMinute: 120
- name: github-write-needs-approval
servers: [github]
tools: ["create_pull_request", "merge_pull_request"]
action: require_approval
reason: Writes to source control require human approval.
- name: k8s-read
servers: [kubernetes]
tools: ["get", "list", "describe", "logs"]
action: allow
maxCallsPerMinute: 60
- name: k8s-no-prod-mutations
servers: [kubernetes]
tools: ["apply", "delete", "scale", "patch"]
argMatch:
namespace: "prod-*"
action: deny
reason: Mutations in production namespaces are forbidden for agents.
The gateway itself reads the same policy as a JSON bundle, {"policies": [...]}, where each entry has
name, namespace, agents, defaultAction and rules. The operator renders ToolPolicy resources
into that bundle. maqpna policy test, policy lint, eval and replay accept both formats.
argMatch compares argument values with globs. Use a dotted path for nested arguments
(metadata.namespace). The reason is what the agent and the audit ledger see when the rule decides.
2. Add conditions where a glob is not enough#
These optional fields narrow a rule further. All of them are AND-ed with the matchers above.
| Field | Matches | Example |
|---|---|---|
principals |
The user the agent acts for (users, notUsers) and their groups (groups, notGroups), as globs |
principals: {users: ["*@acme.eu"], groups: [treasury]} |
scopes |
Scopes in the session token | scopes: ["payments:write"] |
when |
Typed argument conditions. Operators: eq, neq, glob, regex (RE2), in, nin, lt, lte, gt, gte, exists, absent, cidr, len_gt, len_lt |
- {arg: amount.value, op: gt, value: "10000"} |
schedule |
Time windows in an IANA time zone: windows and exceptWindows such as "Fri 16:00-Mon 08:00", "Mon-Fri 08:00-18:00", "Sat,Sun" |
schedule: {timezone: Europe/Berlin, windows: ["Fri 16:00-Mon 08:00"]} |
sessionTaints, sinks |
Taint labels on the session and sink labels on the tool | See Taint and prompt-injection containment |
approval |
For require_approval: minApprovers, approverGroups, userConfirmation: ciba |
See Human approvals |
A when condition on a value too long to check (over 64 KiB for regex) fails closed: a deny or
require_approval rule matches and an allow rule does not.
This rule from config/samples/toolpolicy-v2-guardrails.yaml holds large euro or franc transfers for two
approvers:
- name: big-transfer-four-eyes
servers: [payments]
tools: [create_transfer]
principals: {users: ["*@acme.eu"], groups: [treasury]}
scopes: ["payments:write"]
when:
- {arg: amount.value, op: gt, value: "10000"}
- {arg: amount.currency, op: in, values: ["EUR", "CHF"]}
- {arg: iban, op: regex, value: "^(DE|AT)[0-9]{20}$"}
action: require_approval
approval: {minApprovers: 2, approverGroups: [treasury-supervisors]}
reason: Four-eyes for large transfers.
A policy can also run in dry-run mode with enforcement: dryRun. The gateway then records what the policy
would have decided but enforces only its dryRunFallback (allow, the default, or defaultAction).
3. Lint it#
maqpna policy lint policies/coder.yaml
Expected output:
1 file(s), 0 error(s), 0 warning(s)
policy lint reports what the operator would refuse to publish, rules that can never match because an
earlier rule shadows them, duplicate rule names, a missing defaultAction and rules that restate the
default. Add --strict to fail on warnings. See maqpna policy lint.
4. Test one call#
maqpna policy test --policy policies/coder.yaml --ns team-a --agent coder \
--server kubernetes --tool delete --args '{"namespace":"prod-eu"}'
Expected output:
{
"decision": {
"action": "deny",
"policy": "coder-policy",
"rule": "k8s-no-prod-mutations",
"reason": "Mutations in production namespaces are forbidden for agents."
},
"request": {
"namespace": "team-a",
"agent": "coder",
"session": "dry-run",
"server": "kubernetes",
"tool": "delete",
"args": {
"namespace": "prod-eu"
}
}
}
Use --expect to turn the test into an assertion. A mismatch exits with status 3:
maqpna policy test --policy policies/coder.yaml --ns team-a --agent coder \
--server github --tool merge_pull_request --expect allow
expected allow, got require_approval
Add --explain to see, for every policy and rule, whether it matched and why not
("why": "server \"kubernetes\" not in servers"). Other inputs: --user, --group, --scope, --taint,
--sink and --at (an RFC 3339 time, for schedules). See maqpna policy test.
5. Write a test suite#
Put a maqpna-test.yaml next to your policies and keep it in version control:
apiVersion: maqpna.com/v1alpha1
kind: PolicyTest
name: coder
policies: [coder.yaml] # ToolPolicy YAML or bundle JSON, relative to this file
defaults: {namespace: team-a, agent: coder, user: alice@acme.eu}
cases:
- name: github reads allowed
server: github
tool: get_issue
expect: allow
expectRule: github-read
- name: pull requests need approval
server: github
tool: create_pull_request
expect: require_approval
- name: prod mutations blocked
server: kubernetes
tool: delete
args: {namespace: prod-eu}
expect: deny
expectRule: k8s-no-prod-mutations
- name: unlisted tools denied
server: slack
tool: post_message
expect: deny
Run every suite under a directory:
maqpna policy test policies/
Expected output:
SUITE CASE EXPECT GOT RULE RESULT
coder github reads allowed allow (github-read) allow coder-policy/github-read PASS
coder pull requests need approval require_approval require_approval coder-policy/github-write-needs-approval PASS
coder prod mutations blocked deny (k8s-no-prod-mutations) deny coder-policy/k8s-no-prod-mutations PASS
coder unlisted tools denied deny deny coder-policy PASS
4 passed, 0 failed, 0 suite error(s)
Cases can also set at (for schedules), user, args and agent. A tasks list runs several steps in
one session, so you can test that reading an issue taints the session and the next pull request is held
(see examples/policy-tests/guardrails/maqpna-test.yaml). In CI, use -o junit and --require-tests,
which fails when a policy file under the directory has no suite.
6. Run a golden task set with maqpna eval#
maqpna eval runs a suite of multi-step tasks against your policies and scores them. The repository ships
one:
maqpna eval --suite examples/eval/suite.yaml
The end of the output:
confusion matrix (rows: expected, columns: actual):
allow require_approval deny error
allow 7 0 0 0
require_approval 0 1 0 0
deny 0 0 3 0
score: 11/11 (100.0%)
It exits with status 3 below --min-score (default 1.0). With --gateway and --agent-token-file, the
same suite runs live against a gateway's loaded policies. See maqpna eval.
7. Replay the new policy against real traffic#
Before you change a policy, see which past decisions it would change. maqpna replay re-evaluates the
calls in an audit ledger:
maqpna replay --ledger .maqpna/audit.jsonl --policy candidate.yaml --namespace dev
Expected output, for a candidate that holds get_time for approval:
replay .maqpna/audit.jsonl with candidate.yaml (baseline: recorded)
records 6 evaluated 4 unchanged 0 changed 0 unknown 4 argsMissing 4
skipped: nonPolicy=2
unknown (decision differs, arguments not captured; --strict-args counts these as changed):
SEQ TIME NAMESPACE AGENT SERVER/TOOL BEFORE AFTER FLAGS
2 2026-10-03T03:48:38Z dev coder echo/get_time allow (baseline-guardrails) require_approval (dev-tightened/time-needs-human) argsMissing
...
no changed decisions
The ledger stores a hash of the arguments, not the arguments. A decision that differs on a call without
captured arguments is reported as unknown and exits 0. To replay argument conditions exactly, enable
argument capture on the gateway and pass --captures DIR --capture-key URI. Add --strict-args to count
unknown as changed (exit 3). See maqpna replay.
8. Load the policy#
Local MAQPNA. maqpna dev up --policy FILE copies a bundle JSON file to .maqpna/policies.json. The
gateway re-reads that file every 2 seconds, so you can edit .maqpna/policies.json while the stack runs.
It must be bundle JSON: the local gateway does not read ToolPolicy YAML.
maqpna dev up --policy my-policies.json
Cluster. Validate and apply the ToolPolicy. The operator renders it into the gateway's policy
ConfigMap; the gateway picks it up without a restart.
maqpna validate -f policies/
maqpna apply -f policies/coder.yaml
maqpna -n team-a policy list
The console's policies page shows the same loaded policies and their rules:

Cedar policies#
A ToolPolicy can carry Cedar text in spec.cedar, evaluated together with its rules. The most
restrictive decision wins, and defaultAction applies only when neither a rule nor a Cedar policy decides.
The schema is config/cedar/maqpna.cedarschema: the principal is Maqpna::Agent::"<namespace>/<agent>",
the action is Maqpna::Action::"callTool" or "callModel", and the resource is
Maqpna::Tool::"<server>/<tool>". Annotations name the rule and set approval requirements:
@id, @reason, @action("require_approval"), @minApprovers, @approverGroups.
apiVersion: maqpna.com/v1alpha1
kind: ToolPolicy
metadata:
name: payments-cedar
namespace: team-a
spec:
agents: ["payer"]
defaultAction: deny
cedar: |
@id("big-transfer")
@action("require_approval")
@minApprovers("2")
@approverGroups("treasury-supervisors")
@reason("transfers above 10000 need two supervisors")
permit (principal in Maqpna::Namespace::"team-a", action == Maqpna::Action::"callTool", resource == Maqpna::Tool::"payments/create_transfer")
when { context.args has amount && context.args.amount > 10000 };
@id("transfers")
permit (principal in Maqpna::Namespace::"team-a", action, resource in Maqpna::Server::"payments");
maqpna policy test --policy payments-cedar.yaml --ns team-a --agent payer \
--server payments --tool create_transfer --args '{"amount":25000}'
"decision": {
"action": "require_approval",
"policy": "payments-cedar",
"rule": "big-transfer",
"reason": "transfers above 10000 need two supervisors",
"approval": {
"minApprovers": 2,
"approverGroups": [
"treasury-supervisors"
]
}
},
Verify#
maqpna policy test DIRpasses in CI on every change.- After loading,
maqpna policy list(cluster) shows the policy, and a test call through the gateway gets the decision you expect:maqpna policy test --gateway URL --ns NS --agent A --server S --tool Tevaluates against the gateway's live policies and the session's taint. - The decision appears in the timeline with its policy and rule:
maqpna dev timeline --lastlocally, ormaqpna audit tailin a cluster.
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
Every call is denied with no_applicable_policy |
No policy's namespace and agents globs match the session |
Check the session's namespace and agent (maqpna dev run --namespace, --agent), then policy test --explain |
default_action denies a call you expected to allow |
No rule matched, so defaultAction: deny applied |
Run policy test --explain and read each rule's why |
| An allow rule never wins | Another applicable policy denies or holds the call; the most restrictive decision wins | List every policy for the namespace; --explain shows each one's contribution |
rate_limited |
The rule's maxCallsPerMinute was reached for this session |
Raise the limit or split the rule |
cedar_unavailable: ... |
The binary or gateway image was built without -tags cedar |
Use a release build, or build images with GO_TAGS=cedar |
policy test exits 3 |
--expect did not match, or a suite case failed |
Read the GOT and RULE columns |
| The local gateway keeps the old policy | .maqpna/policies.json is not valid bundle JSON, so the gateway keeps the last good one |
Check .maqpna/logs/maqpna-gateway.log and validate the file with jq . .maqpna/policies.json |
Next steps#
- Hold risky calls for a person: Human approvals.
- Contain prompt injection with taint rules: Taint and prompt-injection containment.
- Pin tool definitions so a changed tool is denied: Connect MCP servers and pin tools.
- Error reasons your agent can receive: Error and reason codes.