Agent policies defined alongside agent code, versioned together in Git, and automatically validated, deployed, and enforced as part of CI/CD — so the running agent and its governance record can never silently diverge the way they do today.
This repo is a complete, runnable reference implementation, not a design doc. Everything described below has been executed end-to-end and is covered by an automated test suite (45 tests, all passing).
git log --oneline
cfafcef Add bonus: environment promotion workflow (dev/staging/prod) ...
49f7a32 Revert "agent v1.1.0: raise max_tokens to 8192, tighten HITL risk_threshold to high"
2963cbf agent v1.1.0: raise max_tokens to 8192, tighten HITL risk_threshold to high
0b0e655 Initial commit: support-triage-agent v1.0.0 + policy v1
That log is itself a demo: commit 2963cbf is a real "agent update"
(code + policy changed together), and 49f7a32 is a real "agent
rollback" (git revert) — restoring both the agent behavior and its
governance record in the exact same commit. That's the core problem
this framework fixes.
The failure mode in the prompt — "the agent's code is versioned in Git, the policy is not; the agent is updated or rolled back, the policy isn't" — happens because the policy lives in a separate system from the code (a governance platform UI, a wiki, a spreadsheet) with its own independent lifecycle. The fix is structural, not procedural:
- The policy file lives in the agent's own repo, next to its code, and is versioned by the same commits, the same PRs, the same branches, the same reverts. There is no separate "governance record" to forget to update — updating the agent is updating the policy, because they're in the same diff.
- Git is the source of historical truth. The versioning endpoint
doesn't maintain its own history table (which could itself drift) —
it reads directly from Git's object database (
git show <sha>:<path>). Any commit that ever existed can be queried, forever. - The enforcement runtime is a separate, mutable system by necessity (something has to actually gate the agent's tool calls in real time), which reintroduces exactly one seam where drift can creep back in: someone editing the runtime directly. The drift detector exists specifically to police that seam.
agent/
src/agent_main.py # stand-in agent code
policy-by-env/{dev,staging,prod}/ # one policy file per environment — single
# source of truth for deploy, versioning,
# drift-check, AND promotion (bonus)
agents.yaml # agent_id -> per-environment policy path registry
schema/policy.schema.yaml # the policy schema (data, not code)
governance/
schema.py # validator (required fields, types, enums, cross-field rules)
git_store.py # policy versioning endpoint logic (reads Git history)
registry.py # agent_id + environment -> policy path lookups
enforcement_store.py # simulated policy enforcement runtime
drift.py # drift detector (enforced vs Git HEAD)
promotion.py # bonus: faithful-promotion check (dev/staging/prod)
api.py # REST API exposing the above over HTTP
cli/
validate_policy.py # CI step 1: block on invalid/incomplete policy
deploy_policy.py # CI step 2: push validated policy to the runtime
check_drift.py # scheduled/on-demand drift check -> alert
promote_policy.py # bonus: generate/verify environment promotions
.github/workflows/
deploy-agent-policy.yml # validate + deploy on push to main
drift-check.yml # scheduled drift detection (cron)
policy-promotion.yml # bonus: PR check gating promotion to prod
.github/CODEOWNERS # bonus: mandatory reviewers for prod policy changes
tests/ # 29 unit tests, stdlib unittest, no network needed
Dependency footprint: PyYAML only. Everything else (the validator,
the diff engine, the HTTP API) is Python stdlib. This is a deliberate
choice, not a limitation of the design — governance/enforcement_store.py
and governance/api.py are the only two modules that would change in a
real deployment (swap the JSON file for a real DB/API client, swap
http.server for FastAPI if you want one), and nothing else in the
framework depends on either of them directly.
schema/policy.schema.yaml defines the schema as data, so schema
changes are themselves reviewable Git diffs. A policy file
(agent/policy-by-env/prod/agent-policy.yaml) declares, per the challenge spec:
| Requirement | Field(s) |
|---|---|
| Approved model list | models.approved, models.default |
| Allowed tools and scopes | tools[].name, tools[].scopes, tools[].requires_hitl |
| Guardrail rules | guardrails.input_filters, guardrails.output_filters, guardrails.max_tokens_per_request, guardrails.pii_handling |
| HITL thresholds | hitl.risk_threshold, hitl.confidence_threshold, hitl.escalation_channel |
| Data retention rules | data_retention.conversation_logs_days, .pii_retention_days, .deletion_on_request |
| Regulatory framework tags | regulatory_frameworks (e.g. SOC2, GDPR) |
The validator also enforces cross-field governance rules a plain required/type schema can't express, e.g.:
models.defaultmust be a member ofmodels.approvedhitl.confidence_thresholdmust be in[0.0, 1.0]- every tool must declare at least one scope
environment: prodrequiresapprovals.requires_review_for_prod: true
pip install pyyaml # the only dependency
# 1) Validate (this is the CI gate)
python cli/validate_policy.py --policy agent/policy-by-env/prod/agent-policy.yaml
# 2) Deploy (push validated policy to the enforcement runtime)
python cli/deploy_policy.py \
--agent-id support-triage-agent --policy agent/policy-by-env/prod/agent-policy.yaml \
--repo . --environment prod
# 3) Query the versioning endpoint for any historical commit
python3 -c "
from governance.git_store import get_policy_at_commit
r = get_policy_at_commit('.', 'support-triage-agent', 'cfafcef', environment='prod')
print(r.policy['data_retention']['pii_retention_days']) # -> 30 (before promotion)
r = get_policy_at_commit('.', 'support-triage-agent', '7bb23c1', environment='prod')
print(r.policy['data_retention']['pii_retention_days']) # -> 14 (after promotion)
"
# 4) Check drift
python3 cli/check_drift.py --agent-id support-triage-agent --environment prod --repo .
# 5) Run the HTTP API
python3 governance/api.py # listens on :8088
curl "localhost:8088/agents/support-triage-agent/policy-at-commit/0b0e655"
curl "localhost:8088/agents/support-triage-agent/drift?environment=prod"
curl "localhost:8088/agents" # list of registered agents + environments
# The governance dashboard is a separate, independently-deployed static
# site (github.com/ashwin937/agent-governance-dashboard) — a read-only
# client of this API, not part of it. This API sends CORS headers
# (Access-Control-Allow-Origin: *) on every response specifically so a
# dashboard hosted on a different origin can call it. See that repo's
# README for how to point it at a given deployment via ?api=<url>.
# 6) Run the full test suite
python3 -m unittest discover -s tests -p "test_*.py" -v"Policy file deploys automatically on push to the main branch."
.github/workflows/deploy-agent-policy.yml runs on every push to
main: it validates the policy, then (only if valid) calls
cli/deploy_policy.py, which pushes it to the enforcement runtime
tagged with the triggering commit SHA. Demonstrated locally in
tests/test_cli_blocks_invalid.py::test_deploy_succeeds_for_valid_policy
and manually against the demo repo (3 real commits, each deployed).
"Policy versioning endpoint returns the correct policy for any
historical commit."
governance/git_store.get_policy_at_commit(repo, agent_id, sha), exposed
over HTTP as GET /agents/{id}/policy-at-commit/{sha}. Verified against
the demo repo's real 3-commit history (v1 → v1.1 update → rollback) —
each SHA returns the exact policy that was active at that commit, and v1
and the rollback commit return identical policy content from two
different SHAs, proving rollback correctness. See
tests/test_versioning.py::test_retrieves_correct_policy_across_multiple_versions.
"Drift detection correctly alerts when the enforced policy is
manually modified out-of-band, simulating an admin change bypassing
Git."
governance/enforcement_store.EnforcementStore.admin_override() simulates
exactly this (as distinct from push_policy(), the CI-only path).
governance/drift.detect_drift() deep-diffs the enforced policy against
Git HEAD, with name-matched diffing for lists like tools so the report
pinpoints the exact field. Verified live against the demo repo (an admin
override raising max_tokens_per_request to 32000 and disabling HITL on
the refund tool was caught precisely) and in
tests/test_drift.py::test_admin_out_of_band_edit_is_detected.
"An invalid policy file with missing required fields fails the CI
step and blocks the deployment."
cli/validate_policy.py exits 1 with every missing/invalid field listed.
The GitHub Actions deploy-policy job has needs: validate-policy, so a
failed validation job structurally prevents the deploy job from ever
running — not just a convention, a hard DAG dependency. deploy_policy.py
also independently re-validates before pushing, so even a
misconfigured pipeline that skipped the validate step can't push a bad
policy. Both paths are tested in tests/test_cli_blocks_invalid.py.
Policies flow dev -> staging -> prod as separate files under
agent/policy-by-env/<env>/agent-policy.yaml. Promoting means opening a
PR that copies the (already-validated) staging file's content into the
prod path — cli/promote_policy.py --write generates that diff.
Two independent gates protect a promotion to prod:
governance/promotion.check_promotion(), run as a required CI check in.github/workflows/policy-promotion.yml, verifies the prod file in the PR is byte-for-byte identical to the current staging file, aside from fields that are legitimately environment-specific (environment,approvals.requires_review_for_prod). This stops a PR labeled "promote to prod" from smuggling in an extra, unreviewed change — seetests/test_promotion.py::test_smuggled_change_fails, demonstrated live by patching in amax_tokens_per_request: 999999change and confirming the check fails.- Human review, enforced natively by GitHub:
.github/CODEOWNERSrequires designated reviewers to approve any change underagent/policy-by-env/prod/, and the promotion job is scoped to theprodGitHub Environment, which you configure with required reviewers in repo Settings → Environments →prod. GitHub then blocks the job — and therefore the merge, once it's a required status check — until that approval lands. (This half is a repo setting, not expressible in YAML, so it's documented here rather than faked.)
governance/enforcement_store.py— swap the JSON file for a client against the real policy enforcement runtime (OPA, Cedar, or a governance platform's own API). Nothing else in the framework touches storage directly.governance/api.py— swap stdlibhttp.serverfor FastAPI/Flask if you want request validation, OpenAPI docs, and auth middleware for free. It exists on stdlib here only because this sandbox has no network access to install packages; every other module is already framework-agnostic.- Auth — the CI→runtime push and the admin-override path both need
real authn/authz in production (service identity for CI, human SSO for
admin edits) so the audit trail (
deployed_by,origin) is trustworthy rather than a caller-supplied string. - Multi-agent scale —
agents.yamlalready supports registering many agents; a real mono-repo would likely generate this registry rather than hand-maintain it. - Registry path lookups use the current tree, not the historical one —
get_policy_at_commitresolves which file to read from today'sagents.yaml, then reads that file's content as of the requested commit. If a policy file is ever moved (as happened here: the original single-file layout was replaced bypolicy-by-env/), querying a commit from before the move will correctly fail with "no policy file at that path" rather than silently returning nothing or the wrong file — but it does mean the versioning endpoint can't answer for commits that predate a path migration. A production version would resolve the path historically too (e.g.git log --follow) if that mattered.