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
maqpnaCLI on your laptop:
curl -fsSL https://maqpna.com/install.sh | sh
- For steps 6 to 8: Docker, kind,
kubectlandhelm, 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
mainand to production fromprod, with the operator'sReadycondition and a livepolicy test --gatewayas 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#
- Write and test policies: every rule condition, Cedar,
maqpna evaland replay. - Test agents and policies: test an agent's code and its policies together.
- Sovereignty, confidential tiers and customer-held keys: what
--sovereigntychecks. - Upgrade and roll back: deliver the MAQPNA chart itself with Argo CD or Flux.
- Signed configuration bundles: push rendered policies to edge gateways without a cluster.