MAQPNADocs

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#

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)
Test a policy offline with maqpna policy test.cast

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:

The console policies page

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 DIR passes 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 T evaluates 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 --last locally, or maqpna audit tail in 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#