MAQPNADocs

Test agents and policies

Check manifests and policies offline, test agent code against a local gateway, and run the same checks in a GitHub Actions pipeline.

Test two things separately. Policies and manifests are data: check them offline, with no cluster and no gateway, in seconds. Agent code is behaviour: run it against a real local gateway, so a denial in a test is the same denial it gets in production. This page shows the commands for both and a CI pipeline that runs them on every pull request.

flowchart LR
    subgraph Offline["Offline, no cluster"]
        V["maqpna validate<br/>schemas, CEL, references"]
        LI["maqpna policy lint"]
        PT["maqpna policy test<br/>PolicyTest suites"]
        EV["maqpna eval<br/>multi-step tasks"]
        RP["maqpna replay<br/>against the ledger"]
    end
    subgraph Local["Against a local gateway"]
        UP["maqpna dev up"] --> UT["maqpna dev run --<br/>pytest / npm test / go test"]
    end
    Offline --> CI["CI: exit 3 fails the job"]
    Local --> CI

Every check exits 0 when it passes and 3 when a check fails, so CI needs no output parsing.

The offline checks#

Command Checks Use it for
maqpna validate -f DIR Every MAQPNA resource against the CRD schemas and CEL rules, unknown fields, that each ToolPolicy compiles, and that spec.tier, spec.policyRef and tools[].serverRef resolve. --sovereignty FILE adds the sovereignty checks. Every manifest change
maqpna policy lint DIR Policy files only: a missing defaultAction, rules shadowed by an earlier rule (first match wins), duplicate rule names, policies with no rules. --strict fails on warnings. Every policy change
maqpna policy test One call, or a suite of cases, against the policies with the operator's own renderer. Proving what a rule does
maqpna eval Multi-step tasks with simulated taint, scored. Prompt-injection and data-flow rules
maqpna replay A candidate policy against real decisions in an audit ledger. Changing a policy that is already in use
maqpna sovereignty check Agents against a SovereigntyPolicy (registry, endpoints, jurisdiction). Regulated deployments

Step 1: Test one call#

Ask "would this agent be allowed to call this tool?" with --explain to see every rule's verdict:

maqpna policy test --policy policies/triage-policy.yaml --ns team-a --agent triage \
  --server github --tool add_issue_comment --explain
{
  "decision": {
    "action": "require_approval",
    "policy": "triage-policy",
    "rule": "comment-needs-approval",
    "reason": "Public comments need a human."
  },
  "trace": {
    ...
        "rules": [
          { "rule": "github-read", "matched": false, "why": "tool \"add_issue_comment\" not in tools" },
          { "rule": "comment-needs-approval", "matched": true }
        ]
}

Add --expect ACTION to turn it into an assertion. A mismatch prints expected deny, got allow and exits 3. Other inputs you can set: --args JSON, --user, --group, --scope, --taint, --sink, --at RFC3339 (for schedules).

Step 2: Write a test suite#

A suite is a file named maqpna-test.yaml. maqpna policy test DIR runs every suite under DIR.

# policy-tests/maqpna-test.yaml
apiVersion: maqpna.com/v1alpha1
kind: PolicyTest
name: triage
policies: [../policies/]
defaults: {namespace: team-a, agent: triage, user: [email protected]}
cases:
  - name: reads are allowed
    server: github
    tool: get_issue
    args: {number: 4821}
    expect: allow
    expectRule: github-read
  - name: comments wait for a human
    server: github
    tool: add_issue_comment
    expect: require_approval
  - name: anything else is denied
    server: github
    tool: delete_repository
    expect: deny
maqpna policy test policy-tests/
SUITE   CASE                       EXPECT               GOT               RULE                                  RESULT
triage  reads are allowed          allow (github-read)  allow             triage-policy/github-read             PASS
triage  comments wait for a human  require_approval     require_approval  triage-policy/comment-needs-approval  PASS
triage  anything else is denied    deny                 deny              triage-policy                         PASS

3 passed, 0 failed, 0 suite error(s)

--require-tests also fails when a policy file is not covered by any suite, and -o junit writes a JUnit report for CI. For multi-step tasks where taint matters (read an untrusted page, then try to send mail), use maqpna eval --suite suite.yaml --policy policies/; examples/eval/suite.yaml in the source tree is a worked example.

Step 3: Test the agent code against a local gateway#

Run your normal test runner under maqpna dev run. Every call the tests make goes through the local gateway, with the local policy, DLP and approvals.

# tests/test_governance.py
import pytest
from maqpna import MaqpnaClient, PolicyDenied, ApprovalPending


@pytest.fixture(scope="module")
def echo():
    return MaqpnaClient.from_env().tools("echo")


def test_read_is_allowed(echo):
    assert echo.call("echo", text="hello").text == "hello"


def test_system_namespace_is_denied(echo):
    with pytest.raises(PolicyDenied) as e:
        echo.call("delete_resource", id="db-1", namespace="kube-system")
    assert e.value.rule == "never-touch-system-namespaces"
    assert e.value.reason == "policy_denied"


def test_delete_needs_approval(echo):
    with pytest.raises(ApprovalPending) as p:
        echo.call_tool("delete_resource", {"id": "db-1", "namespace": "dev"}, async_mode=True)
    assert p.value.approval_id.startswith("apr_")
maqpna dev up
maqpna dev run -- python -m pytest -q tests/
maqpna dev: session dev-3c7a14ec (agent coder, namespace dev, on behalf of you@localhost)
...                                                                      [100%]
3 passed in 0.33s
maqpna dev: dev-3c7a14ec: 1 allow, 1 deny, cost $0.0000 (maqpna dev timeline --last)
// test/governance.test.mjs (node --test)
import { test } from "node:test";
import assert from "node:assert/strict";
import { MaqpnaClient, PolicyDenied } from "@kosa8/maqpna";

const echo = MaqpnaClient.fromEnv().tools("echo");

test("system namespace is denied", async () => {
  await assert.rejects(
    echo.call("delete_resource", { id: "db-1", namespace: "kube-system" }),
    (e) => e instanceof PolicyDenied && e.reason === "policy_denied",
  );
});
maqpna dev run -- node --test test/
func TestSystemNamespaceDenied(t *testing.T) {
    ctx := context.Background()
    client, err := maqpna.NewFromEnv(ctx)
    if err != nil {
        t.Skip("not under maqpna dev run:", err)
    }
    _, err = client.CallTool(ctx, "echo", "delete_resource",
        map[string]any{"id": "db-1", "namespace": "kube-system"})
    var denied *maqpna.PolicyDenied
    if !errors.As(err, &denied) || denied.Reason != "policy_denied" {
        t.Fatalf("want policy_denied, got %v", err)
    }
}
maqpna dev run -- go test ./...

Tips:

  • Use --agent, --namespace, --user and --scope on maqpna dev run to test as the identity your policy targets.
  • Load your own rules with maqpna dev up --policy local/policies.json (bundle JSON; see Anatomy of an agent project).
  • Point tests at a fake MCP server with --upstream NAME=URL, and at the scripted model with --stub-llm (--stub-llm-script FILE changes its answers).
  • For fast unit tests with no gateway at all, the SDK test suites show how to inject a fake transport: sdk/python/tests/, sdk/typescript/test/ (a custom fetch) and sdk/go (an httptest server with WithHTTPClient).

Step 4: Check a change against real traffic#

Before you tighten a policy that is in use, replay the audit ledger against the candidate. It prints only the decisions that would change, and exits 3 if any do:

maqpna replay --ledger .maqpna/audit.jsonl --policy local/policies.json --namespace dev

The ledger stores a hash of the arguments, not the arguments, so calls with arguments are replayed without them and flagged argsMissing; their changes count as unknown, not changed. See Write policies.

Step 5: Run it in CI#

This GitHub Actions workflow runs the offline checks and the agent tests against a local MAQPNA. It needs no cluster and no secrets.

# .github/workflows/maqpna.yml
name: maqpna
on: [pull_request]

jobs:
  policies:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install the maqpna CLI
        run: |
          curl -fsSL https://maqpna.com/install.sh | sh -s -- --version v2.1.0 --bin maqpna --dir "$HOME/.local/bin"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - name: Validate manifests
        run: maqpna validate -f manifests/ -f policies/ --strict -o junit > validate.xml
      - name: Lint policies
        run: maqpna policy lint policies/ --strict
      - name: Policy test suites
        run: maqpna policy test policy-tests/ --require-tests -o junit > policy-tests.xml
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: maqpna-reports
          path: "*.xml"

  agent:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Install the maqpna CLI
        run: |
          curl -fsSL https://maqpna.com/install.sh | sh -s -- --version v2.1.0 --bin maqpna --dir "$HOME/.local/bin"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - name: Install the agent and the SDK
        run: pip install -r requirements.txt pytest   # requirements.txt lists kosa8-maqpna
      - name: Start a local MAQPNA
        run: maqpna dev up --stub-llm --policy local/policies.json
      - name: Agent tests through the gateway
        run: maqpna dev run --agent triage --namespace team-a -- python -m pytest -q tests/
      - name: Verify the audit ledger
        if: always()
        run: maqpna audit verify .maqpna/audit.jsonl
      - name: Show the gateway logs on failure
        if: failure()
        run: tail -n 100 .maqpna/logs/*
      - name: Stop
        if: always()
        run: maqpna dev down

maqpna dev up downloads maqpna-gateway and maqpna-identity of the CLI's version on the first run and checks them against the release checksums. A step fails on exit status 3 (a failed check) as well as 1 (an error).

What can go wrong#

Symptom Cause Fix
policy lint reports invalid-policy ... Agent is not a policy The directory mixes policies with other kinds. Keep ToolPolicy files in their own directory.
Suite case passes offline, fails against the gateway Another applicable policy (strictest wins), DLP or a pin decided. maqpna policy test --gateway "$MAQPNA_GATEWAY_URL" ... evaluates the gateway's live policies.
no_applicable_policy in agent tests The local bundle does not select the maqpna dev run identity. Match --agent/--namespace to the bundle, or the bundle to coder/dev.
Agent tests hang A call is held for approval in sync mode. Use async mode in tests and assert ApprovalPending.

Next steps#