Agent Approvals¶
Human-in-the-loop (HiTL) gate for agent-initiated mutating actions — powered by approval-service, a policy ConfigMap, and enforcement inside the MCP tool servers themselves.
Shipped as ADP Phase 4. See Agentic Development Platform for how it fits the wider agent architecture.
The Problem¶
Agents that can only read are safe and not very useful. The moment an agent can sync an ArgoCD app, roll back a deployment, or approve a pull request, "the system prompt tells it to ask first" stops being a control — it's a suggestion the model is free to skip, and nothing in the platform records that it did.
The Solution¶
A mutating tool call fails unless it carries an approval_id whose recorded status is approved. The check lives in the tool server's own code path, not in the prompt, so an agent cannot talk its way past it. Which actions need a human is data (a ConfigMap), not code — and every request, auto-approval, and human decision lands in an audit log.
How it works¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
| Piece | Path |
|---|---|
REST API + agent_approvals table |
services/approval-service/ |
| Policy rules | kubernetes/kagent/policies/configmap.yaml → src/policy.ts |
Agent-facing tools (check_policy, request_approval, get_approval_status) |
services/idp-mcp-server/src/server.ts |
Enforcement on sync_app / rollback_app |
services/argocd-mcp-server/src/server.ts (requireApproval) |
Enforcement on approve_pr |
services/github-mcp-server/src/server.ts |
| Approvals UI | backstage/app/packages/app/src/extensions.tsx (ApprovalsPage) |
The agent_approvals table lives on the existing Backstage Postgres — Aurora/RDS on AWS, the docker-compose pgvector container locally (reached from Kind pods via host.docker.internal). No new database.
Enforcement is opt-in, and silent when off¶
requireApproval() returns immediately if APPROVAL_SERVICE_URL is unset, so a tool server running without the gate still works. An unset APPROVAL_SERVICE_URL means mutating tools run ungated — it is the first thing to check when the gate appears not to work. The servers log a warning naming the action and target on every such call, so it leaves a trace in the pod logs rather than passing silently.
APPROVAL_SERVICE_URL is declared in each server's helm-values-aws.yaml and helm-values-local.yaml, which means the gate is on by default. It was previously patched in by bootstrap-ai.sh --adp with kubectl set env, making it opt-in. That stopped working when the MCP services were adopted into ArgoCD: set env is an out-of-band mutation, so selfHeal reconciled the Deployments back to their git-declared spec and stripped the variable. Every mutating agent tool ran ungated for as long as that went unnoticed, while every Application reported Synced/Healthy. Anything that must reach a pod belongs in a values file, not in a kubectl call after the fact.
What the gate actually verifies¶
Beyond "is it approved", requireApproval() rejects a mismatched action/target pair — an approval issued for sync_app on dev-hello-service cannot be replayed against prod-payments-api. Without that check the gate would be a rubber stamp.
Policy¶
kubernetes/kagent/policies/configmap.yaml is mounted at /etc/approval-service/policy.json. Rules are evaluated top to bottom, first match wins, so specific patterns must precede *:
| Action | Target pattern | Requires approval | Rationale |
|---|---|---|---|
sync_app |
prod-* |
yes | Production sync always needs a human |
sync_app |
* |
no | Non-prod sync is reversible by another sync |
rollback_app |
* |
yes | Reverts a live deployment regardless of environment |
approve_pr |
* |
yes | Merge-enabling action |
request_changes |
* |
no | Blocks merge rather than enabling it |
targetPattern supports exact match, a prefix-* glob, and *. An action with no matching rule fails safe — it requires approval (matchedRule: "default-fallback"). Adding a new mutating tool therefore defaults to gated, not open.
src/policy.ts also carries a DEFAULT_POLICY used only when the ConfigMap isn't mounted (local npm run dev, unit tests). It is stricter than the shipped policy — every sync_app requires approval. Don't read it as the deployed behaviour.
Policy is cached at first read. Editing the ConfigMap requires a pod restart to take effect:
1 2 | |
Using it from Backstage¶
Open http://backstage.idp.local/approvals (nav item: Agent Approvals). The page lists pending approvals by default, with an All (last 100) toggle for history, and shows each request's action, target, requesting agent, and the free-form context the agent supplied — its stated reason for wanting the action.
Approve or deny writes decided_by from your Backstage identity. The agent's next get_approval_status call sees the decision and either retries or reports the denial.
The page requires bootstrap-ai.sh --adp to have run: it reveals the route in the generated local/backstage/app-config.ai.yaml, and Backstage reads config only at startup, so restart it afterwards with ./scripts/bootstrap-local.sh --start-backstage. Without that, the route is disabled; with the route on but approval-service down, the page shows HTTP … — is approval-service deployed?. See AI Assistant § Where the AI pages come from for how that gating works.
API reference¶
Base URL: http://approval-service.idp.local (local) · http://approval-service.services-dev.svc.cluster.local:3009 (in-cluster) · /api/proxy/approval-service (through Backstage).
| Method | Path | Body / query | Returns |
|---|---|---|---|
POST |
/policy/check |
{action, target} |
{requires_approval, reason, matched_rule} |
POST |
/approvals |
{action, agent, target, context?} |
201 — the approval, already approved if policy auto-approves |
GET |
/approvals |
?status=pending |
{total, approvals[]} (unfiltered: last 100) |
GET |
/approvals/:id |
— | the approval, or 404 |
POST |
/approvals/:id/decide |
{decision: approved\|denied, decided_by} |
the updated approval, or 409 |
GET |
/healthz · /ready · /metrics |
— | health, readiness, Prometheus metrics |
decide is guarded by WHERE status = 'pending', so a second decision on the same approval returns 409 rather than overwriting the first — decisions are immutable once made.
Audit log¶
approval-service emits [AUDIT] JSON lines for approval_requested, approval_auto_approved, and approval_decided, each carrying approval_id; gated tools' own [AUDIT] entries carry the approval_id they were given. Together they reconstruct who authorised what, when, and on whose behalf.
1 | |
See AI Assistant § Structured audit log.
Testing the gate¶
Unit tests¶
1 2 | |
End-to-end, local¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
Cases worth asserting, because each is a different failure mode:
| Case | Expected |
|---|---|
sync_app on dev-* |
created already approved, decided_by: policy:auto-approve |
unknown action (delete_cluster) |
requires_approval: true (fail-safe) |
| deciding the same ID twice | 409 |
sync_app real call, no approval_id |
rejected: "Approval required for …" |
| replaying an approval on a different app | rejected: "requested for a different action/target" |
| retry after a denial | rejected: "is not approved (status: denied)" |
Troubleshooting¶
| Symptom | Cause |
|---|---|
| Mutating tools succeed with no approval | APPROVAL_SERVICE_URL unset on that tool server. Check the pod's env, not the values file — a live sync can differ from git. Look for the "approval gate disabled" warning in the pod logs |
/approvals 404s or is missing from the nav |
AI overlay written with the layer off, or Backstage not restarted since |
| Approvals page shows an HTTP error | approval-service not running, or the proxy target in app-config.local.yaml / app-config.aws.yaml unreachable |
| Pod crash-loops on start | Postgres unreachable — locally the compose Postgres must be up and reachable at host.docker.internal:5432; on AWS approval-service-db is copied from backstage-secrets, which requires bootstrap.sh to have run |
| Policy edits have no effect | Policy is cached — kubectl rollout restart deployment/approval-service -n services-dev |
409 on every decide |
The approval was already decided; decisions are one-shot by design |