MAQPNADocs

Policy as code with CI and GitOps

Keep MAQPNA policies in Git, lint and test them in GitHub Actions on every pull request, and promote them from staging to production with GitOps.

In this tutorial you put an agent's policies in a Git repository and treat them like code. Every pull request runs the same offline checks the operator runs: lint, unit tests, schema and sovereignty validation. A merge to main reaches a staging cluster through GitOps; a second merge promotes the same commit to production.

What you'll build#

flowchart LR
  Dev["Edit policy<br/>+ test case"] --> PR["Pull request"]
  PR --> CI{"GitHub Actions<br/>lint · test · validate"}
  CI -->|fail| Dev
  CI -->|pass| Main["merge to main"]
  Main --> Stg["Argo CD<br/>staging cluster"]
  Stg --> Check["policy Ready=True<br/>live decision checked"]
  Check --> Prod["merge main → prod<br/>Argo CD production"]
Time About 45 minutes
Level Intermediate
You learn The PolicyTest suite format, maqpna policy lint, maqpna policy test --require-tests, maqpna validate --sovereignty, maqpna diff, and how the operator publishes a ToolPolicy to the gateway

A policy (ToolPolicy) is a named set of rules that decide which tool calls an agent may make. Each rule gives an action: allow, deny or require_approval. The Write and test policies guide explains every rule field; this tutorial is about the workflow around them.

Prerequisites#

  • A GitHub repository you can push to, with GitHub Actions turned on.
  • The maqpna CLI on your laptop:
curl -fsSL https://maqpna.com/install.sh | sh
  • For steps 6 to 8: Docker, kind, kubectl and helm, to run a staging cluster on your laptop. Any cluster with MAQPNA installed works too.

No cluster, gateway or API key is needed for steps 1 to 5. Every check in them runs offline.

Step 1: Lay out the repository#

Create the repository and the three folders. Policies and their tests live in separate folders: policy lint reports a test suite file as an invalid policy when the two share a folder.

mkdir agent-policies && cd agent-policies
git init -b main
mkdir -p policies policy-tests/coder manifests sovereignty

policies/ holds one ToolPolicy per file, policy-tests/ one suite folder per policy, manifests/ the Agent, MCPServer and TrustTier resources the policies refer to, and sovereignty/ the SovereigntyPolicy.

Generate starter manifests from the samples built into the CLI. maqpna init writes a manifest to stdout:

maqpna init toolpolicy coder-policy --from-sample toolpolicy-coder --namespace team-a > policies/coder-policy.yaml
maqpna init agent coder --from-sample agent-coder --namespace team-a > manifests/agent-coder.yaml
maqpna init mcpserver github --from-sample mcpserver-github --namespace team-a > manifests/mcpserver-github.yaml
maqpna init tier tier-0-gvisor --from-sample tier-0-gvisor > manifests/tier-0-gvisor.yaml
maqpna init sovereigntypolicy default --from-sample sovereigntypolicy-eu > sovereignty/eu.yaml

policies/coder-policy.yaml applies to agents coder and coder-* in namespace team-a, denies by default, allows GitHub and Kubernetes reads, holds pull requests for approval and denies changes in prod-* namespaces. Run maqpna init --list to see the other samples.

Check it works#

maqpna policy lint policies/
1 file(s), 0 error(s), 0 warning(s)

Step 2: Write a test suite#

A test suite is a PolicyTest file named maqpna-test.yaml; each case is one tool call and the decision you expect. Create policy-tests/coder/maqpna-test.yaml:

apiVersion: maqpna.com/v1alpha1
kind: PolicyTest
name: coder
policies: [../../policies/coder-policy.yaml]   # relative to this file
defaults: {namespace: team-a, agent: coder, user: [email protected]}
cases:
  - name: github reads are 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
    expectRule: github-write-needs-approval
  - name: prod mutations are blocked
    server: kubernetes
    tool: delete
    args: {namespace: prod-eu}
    expect: deny
    expectRule: k8s-no-prod-mutations
  - name: unlisted tools fall to the default deny
    server: slack
    tool: post_message
    expect: deny
Field Meaning
policies Policy files or folders, relative to the suite. List files one by one so --require-tests knows which file each suite covers
defaults Namespace, agent and user used by every case unless the case sets its own
expect allow, deny or require_approval
expectRule Optional: the rule that must make the decision (rule or policy/rule)
args, at, user, agent Optional per-case inputs; at is an RFC 3339 time for schedule rules

A tasks: list adds multi-step cases that share one session, for taint rules. See Write a test suite.

Check it works#

Run every suite under policy-tests/, and pass policies/ too so --require-tests can find policy files that no suite covers:

maqpna policy test policy-tests/ policies/ --require-tests
SUITE  CASE                                     EXPECT                                          GOT               RULE                                      RESULT
coder  github reads are allowed                 allow (github-read)                             allow             coder-policy/github-read                  PASS
coder  pull requests need approval              require_approval (github-write-needs-approval)  require_approval  coder-policy/github-write-needs-approval  PASS
coder  prod mutations are blocked               deny (k8s-no-prod-mutations)                    deny              coder-policy/k8s-no-prod-mutations        PASS
coder  unlisted tools fall to the default deny  deny                                            deny              coder-policy                              PASS

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

The test uses the operator's own renderer, so it evaluates exactly what the gateway would load. Add a second policy file without a suite and the run fails with exit status 3:

no test covers policy file policies/support-policy.yaml (--require-tests)

Step 3: Validate the manifests#

maqpna validate checks every manifest against the CRD schemas and CEL rules, compiles each ToolPolicy, resolves references between files (an agent's policyRef, tier and tools[].serverRef) and, with --sovereignty, checks agents and MCP servers against the sovereignty policy:

Check it works#

maqpna validate -f manifests/ -f policies/ --sovereignty sovereignty/eu.yaml --strict
4 file(s), 4 object(s): 0 error(s), 0 warning(s)

--strict turns warnings into failures. Without the tier and MCP server files, for example, the agent's references cannot be resolved: an unresolved-reference finding, informational by default and a warning with --strict.

Commit the starting point: git add . && git commit -m "Coder policy, tests and manifests".

Step 4: Run the checks in GitHub Actions#

Create .github/workflows/policies.yml. The job installs a pinned release of the CLI with the same installer you used, then runs the three checks with JUnit output so failures show per case:

name: policies

on:
  pull_request:
  push:
    branches: [main, prod]

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    env:
      MAQPNA_VERSION: v2.1.0          # pin the CLI; bump it in its own pull request
    steps:
      - uses: actions/checkout@v4

      - name: Install the maqpna CLI
        run: |
          curl -fsSL https://maqpna.com/install.sh | sh -s -- --bin maqpna --dir "$HOME/.local/bin"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"

      - name: Lint policies
        run: maqpna policy lint policies/ --strict

      - name: Test policies
        run: maqpna policy test policy-tests/ policies/ --require-tests -o junit > policy-tests.xml

      - name: Validate manifests and sovereignty
        if: always()
        run: |
          maqpna validate -f manifests/ -f policies/ \
            --sovereignty sovereignty/eu.yaml --strict -o junit > validate.xml

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: policy-reports
          path: "*.xml"

The installer reads MAQPNA_VERSION, checks each download against its SHA-256 and, when cosign is on the runner, against its signature. --bin maqpna installs only the CLI; CI does not need the maqpna-install plugin.

Every command exits with status 3 when a check fails, so the job fails. Make check a required status check for main and prod.

Check it works#

Push the repository and open the Actions tab. The policies workflow passes, and the policy-reports artifact holds policy-tests.xml and validate.xml.

Step 5: Change a policy test-first#

Agents may now scale workloads in staging-* namespaces, but only with a human approval. Write the test first. On a branch, add this case to policy-tests/coder/maqpna-test.yaml:

  - name: staging scale-ups need approval
    server: kubernetes
    tool: scale
    args: {namespace: staging-eu}
    expect: require_approval
    expectRule: k8s-staging-scale-needs-approval

Run git switch -c staging-scale, then maqpna policy test policy-tests/ policies/ --require-tests. The new case fails: no rule matches, so the policy's default deny applies.

coder  staging scale-ups need approval          require_approval (k8s-staging-scale-needs-approval)  deny              coder-policy                              FAIL

4 passed, 1 failed, 0 suite error(s)

Add the rule at the end of rules: in policies/coder-policy.yaml:

  - name: k8s-staging-scale-needs-approval
    servers: [kubernetes]
    tools: ["scale"]
    argMatch:
      namespace: "staging-*"
    action: require_approval
    reason: Scaling staging workloads needs a human.

Rules inside a policy are checked in order and the first match wins. The earlier k8s-no-prod-mutations rule still denies scale in prod-*, and your new rule cannot shadow it.

Check it works#

maqpna policy lint policies/
maqpna policy test policy-tests/ policies/ --require-tests
coder  staging scale-ups need approval          require_approval (k8s-staging-scale-needs-approval)  require_approval  coder-policy/k8s-staging-scale-needs-approval  PASS

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

Commit, push and open a pull request. The workflow runs on it.

Step 6: Start a staging cluster#

Create a kind cluster and install MAQPNA with the dev profile. REGISTRY is the release registry listed on maqpna.com/download. For other clusters, see Install on k3s or kind.

kind create cluster --name maqpna-staging
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v1.0.6/sandbox-with-extensions.yaml
maqpna install --profile dev --chart "oci://$REGISTRY/charts/maqpna" --version 2.1.0 --wait
kubectl create namespace team-a

maqpna install needs the maqpna-install plugin, which install.sh installs by default.

Run maqpna status to check that the release maqpna is deployed in maqpna-system.

Step 7: Deliver policies with Argo CD#

The operator watches every ToolPolicy in the cluster, renders them into the gateway's policy ConfigMap (maqpna-policies in maqpna-system) and sets the policy's Ready condition. The gateway reloads the ConfigMap without a restart. So any tool that applies Kubernetes resources from Git can deliver policies. This step uses Argo CD; Flux works the same way.

Install Argo CD in the staging cluster:

kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl -n argocd rollout status deploy/argocd-repo-server

Create argocd/staging.yaml in your repository. Staging follows main. Use server-side apply, which is what maqpna apply uses:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: agent-policies-staging
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/<your-org>/agent-policies.git
    targetRevision: main
    path: policies
  destination:
    server: https://kubernetes.default.svc
  syncPolicy:
    automated: {prune: true, selfHeal: true}
    syncOptions: [ServerSideApply=true]

Before you apply it, see what the first sync will change. maqpna diff runs maqpna validate and a server-side dry run, and exits with status 3 when there are differences:

maqpna diff -f policies/
kubectl apply -f argocd/staging.yaml   # for a private repository, add its credentials to Argo CD first

Check it works#

Merge the staging-scale pull request. Within Argo CD's sync interval the policy is in the cluster and published:

maqpna wait toolpolicy/coder-policy -n team-a --for condition=Ready --timeout 5m
maqpna get tp -n team-a

The READY column is True, and kubectl get tp coder-policy -n team-a -o yaml shows the reason Published. A policy the operator refuses gets Ready=False with reason InvalidPolicy and is not published; the gateway keeps the last good set. Your CI checks catch these before the merge.

To check the live decision, port-forward the gateway and ask it. The dev profile's gateway accepts the static admin token from the maqpna-tokens Secret:

maqpna port-forward gateway 8080:8080 &
export MAQPNA_ADMIN_TOKEN="$(kubectl -n maqpna-system get secret maqpna-tokens -o jsonpath='{.data.admin-token}' | base64 -d)"
maqpna policy test --gateway http://127.0.0.1:8080 --ns team-a --agent coder \
  --server kubernetes --tool scale --args '{"namespace":"staging-eu"}' --expect require_approval

Step 8: Promote to production#

Production follows a prod branch. Promotion is a pull request from main to prod, so production only ever runs a commit that passed CI and ran in staging.

Create the branch with git switch -c prod main && git push -u origin prod. Add argocd/production.yaml and apply it to the production cluster's Argo CD:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: agent-policies-production
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/<your-org>/agent-policies.git
    targetRevision: prod
    path: policies
  destination:
    server: https://kubernetes.default.svc
  syncPolicy:
    automated: {prune: true, selfHeal: true}
    syncOptions: [ServerSideApply=true]

To promote, open a pull request from main into prod. The same policies workflow runs on it, and branch protection can require a second reviewer.

Check it works#

After the promotion merges, run the same maqpna wait and maqpna policy test --gateway commands against the production cluster. Every policy change is now a Git commit, a CI run and an audit record of the decisions it made.

What you built#

  • A repository where policies, their tests and the manifests they refer to are reviewed like code.
  • A GitHub Actions job that lints, tests (with coverage enforced by --require-tests) and validates against the sovereignty policy on every pull request, with JUnit reports.
  • A test-first change: a failing case, then the rule that makes it pass.
  • GitOps delivery to staging from main and to production from prod, with the operator's Ready condition and a live policy test --gateway as the check after each sync.

Clean up#

kubectl delete -f argocd/staging.yaml
kind delete cluster --name maqpna-staging

Removing every policy for a namespace does not open it up: with no applicable policy, the gateway denies every call with reason no_applicable_policy.

Next steps#