The enforced companion to AGENTS.md.
One YAML file declaring what your AI coding agent may not do, compiled to the native permission config of whichever runtime you use. An attempt at a specification for standardizing agentic permission enforcement, treating enforcement as a layer separate from the written context standardized by AGENTS.md.
Status: Version: 0.1.0-draft. Nothing is stable. See open questions.
AGENTS.md is the settled standard for agent context. However, it lacks an enforcement layer.
The emerging convention is three tiers — always do, ask first, never do — written like this:
## Boundaries
- Never commit secrets
- Don't touch infra/prod/
- Never force-push to mainThose are three sentences in a context window. The model usually honors them. "Usually" is not a security control.
Meanwhile every major runtime already has a real permission system that would enforce exactly these rules, and each uses a different format:
| Runtime | Config |
|---|---|
| Claude Code | .claude/settings.json |
| Codex | .codex/config.toml |
| OpenCode | opencode.json |
| Cursor | .cursor/rules/, hooks.json |
So the tier everyone writes in prose is unenforced, and the enforcement that exists is
fragmented four ways. agent-policy is the compile step between them.
AGENTS.md prose, into the model's context — how to work
agent-policy.yaml source of truth — what is forbidden
│
└── compile ──┬──> .claude/settings.json native enforcement
├──> .codex/config.toml native enforcement
├──> opencode.json native enforcement
└──> .agent-policy/hook.py fallback, remainder only
The split is by whether a rule has a deterministic trigger:
- "Use conventional commits" → no trigger →
AGENTS.md - "Never force-push to main" → fires on a specific argv →
agent-policy.yaml
Both are git workflow. Different files.
Keep a Boundaries section in AGENTS.md, shortened to a summary that points here — not
for redundancy, but so the agent knows the shape of its constraints instead of
discovering them by getting blocked. The prose informs and the policy enforces. See the
example AGENTS.md in this repo for what that looks like.
- Native runtime config (compiled). Always preferred — battle-tested, and applied before the agent takes its first turn.
- The bundled hook. For runtimes with no native permission system, or for residual rules no native target could express.
- OS sandbox. Underneath both. A
Readdeny rule blocks the built-in file tool, not a Python script that opens the file itself. Guardrail, not containment.
This is the spec and reference implementation. You read it here and install pieces into your own projects.
├── SPEC.md # normative: precedence, categories, target mappings
├── agent-policy.schema.json # JSON Schema (draft 2020-12)
├── agent-policy.yaml # blank template — copy to your repo and fill in
├── AGENTS.md # blank template — the advisory half of the pair
├── compile/
│ ├── export_claude_code.py # policy -> .claude/settings.json
│ ├── import_claude_code.py # existing settings.json -> policy (onboarding)
│ ├── export_codex.py # policy -> .codex/config.toml (permission profile)
│ ├── import_codex.py # existing config.toml -> policy (onboarding)
│ ├── export_opencode.py # policy -> opencode.json (per-tool permissions)
│ └── import_opencode.py # existing opencode.json -> policy (onboarding)
├── enforcers/
│ └── pretooluse_hook.py # fallback enforcer
├── tools/
│ ├── validate.py # CI validator + merge implementation
│ └── sync_agents_md.py # render/check the AGENTS.md Boundaries summary
├── tests/
│ └── test_agent_policy.py # conformance fixtures (stdlib unittest)
└── examples/
└── python-fastapi/ # filled worked example: agent-policy.yaml + paired AGENTS.md
The root agent-policy.yaml and AGENTS.md are blank templates — every field present,
nothing filled in — meant to be copied into your repo and completed. examples/ holds the
same pair filled in for a real (fictional) project, as a reference.
Already have Claude Code permission rules? Import them once:
python3 compile/import_claude_code.py .claude/settings.json > agent-policy.yamlStarting fresh? Copy the blank agent-policy.yaml and
AGENTS.md templates (plus agent-policy.schema.json) to your repo root and
fill them in — every field is present, empty. A filled reference is in
examples/python-fastapi/. A short enforced policy beats a long
one that gets switched off.
Then compile:
python3 compile/export_claude_code.py --report # see coverage first
python3 compile/export_claude_code.py # write .claude/settings.jsonThe report tells you what compiled natively and what fell through to the hook:
claude-code: 47 permission rules, 5 allowed + 0 denied domains
2 rule(s) not expressible natively -> hook target:
- escalation: no native equivalent (approver/timeout)
- audit: no native equivalent (structured decision log)
On Codex? Same policy, different target:
python3 compile/export_codex.py --report # see coverage first
python3 compile/export_codex.py # write .codex/config.toml
python3 compile/import_codex.py .codex/config.toml > agent-policy.yaml # or onboardCodex ≥ 0.119 has a fine-grained permission profile — per-path read/write/deny and
per-domain network allow/deny — so export_codex.py compiles a [permissions.agent-policy]
profile that enforces read/write/network natively (only exec falls to the hook, since
Codex keeps command policy in a separate execpolicy mechanism). For older Codex, set
targets.codex.style: sandbox for the coarse sandbox_mode posture. See SPEC.md.
On OpenCode? Same again:
python3 compile/export_opencode.py --report # see coverage first
python3 compile/export_opencode.py # write opencode.json
python3 compile/import_opencode.py opencode.json > agent-policy.yaml # or onboardOpenCode's permission block is per-tool (read/edit/bash/… → {pattern: action}),
so read/write/exec compile natively. Only network falls to the hook, because
webfetch/websearch are whole tool toggles with no per-domain form. See SPEC.md.
Sync your AGENTS.md. Drop two markers into your AGENTS.md and let the policy fill the
Boundaries summary between them:
python3 tools/sync_agents_md.py --write # render the block from the policy
python3 tools/sync_agents_md.py # check for drift (CI gate)Everything outside the markers stays yours; the block between them is generated, so the summary can never quietly fall out of step with what is enforced.
Add CI:
# .github/workflows/agent-policy.yml
name: agent-policy
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install pyyaml jsonschema
- run: python3 tools/validate.py
- run: python3 tools/sync_agents_md.py # fail if AGENTS.md drifted from the policy
- run: python3 tests/test_agent_policy.py
- run: |
python3 compile/export_claude_code.py
python3 compile/export_codex.py
python3 compile/export_opencode.py
git diff --exit-code .claude/settings.json .codex/config.toml opencode.jsonThe last step fails the build if any compiled output is stale, ensuring that the policy and the config it generates never silently diverge.
Precedence is deny → ask → allow, first match wins, deny beats allow.
Categories are runtime-neutral. Tool names differ across runtimes (Bash vs shell);
capabilities do not. The compiler owns the mapping.
version: "0.1.0-draft"
mode: warn
permissions:
deny:
read: ["**/.env", "secrets/**"]
write: ["infra/prod/**", ".github/workflows/**"]
exec: ["git push --force", "terraform apply", "curl"]
ask:
exec: ["git push", "alembic upgrade"]
allow:
write: ["src/**", "tests/**", "docs/**"]
network: ["github.com", "pypi.org"]
targets:
claude-code:
output: .claude/settings.json
merge: true
hook:
output: .agent-policy/hook.py
covers: remainderWrite bare command prefixes - the compiler adds each target's wildcard syntax. The full category and precedence semantics, and every per-target mapping, are in SPEC.md.
mode (audit / warn / enforce) applies to the hook only — native targets
enforce unconditionally once compiled. Start at warn to learn your false-positive rate
before promoting to enforce. Full behavior table in SPEC.md.
One agent-policy.yaml per directory, merged root-down — denials union, allowances
intersect, mode from the root, so a subdirectory can tighten a policy but never weaken
it (the inverse of AGENTS.md's nearest-file-wins). tools/validate.py --effective <dir>
prints the resolved policy. Normative rules in SPEC.md.
- Replacing AGENTS.md. Adopt it. This handles one section of it.
- A new enforcement engine. The runtimes already have those. This targets them.
- Agent topology. How agents and tools wire together is a separate problem, addressed by things like Oracle's Agent Spec.
- Roles or personas. No opinion on what a "Debugger agent" is.
- A sandbox. Policy expression, not isolation. Use a container for containment.
Compiled output lands where the runtime reads it before the agent's first turn, so the
agent cannot edit its own permissions mid-session. Better than a policy file it can
write to. Still not a guarantee as an agent with repo write access can edit
agent-policy.yaml and recompile.
The honest fix is enforcing from outside the workspace. v0.1.0-draft does not solve this and does not attempt to.
v0.1.0-draft covers claude-code, codex, and opencode (each with export + import), a fallback hook with a documented contract, nested-policy resolution, and AGENTS.md drift detection. What is intended next, roughly in order:
More targets
- Cursor. Compile to
.cursor/rules/(MDC) andhooks.json. Its model is prose rules plus lifecycle hooks rather than a permission table, so the split between what maps natively and what falls to the hook will differ again — the next update. - Codex execpolicy.
execrules are the one thing Codex config.toml cannot express; generating a Codex execpolicy would move per-command rules from the hook to native. - Claude
WebFetch(domain:…). Extend network enforcement from sandboxed shell egress to theWebFetchtool via permission rules (today the hook covers the tool path).
Solidifying the hook
- Freeze per-runtime adapter wire formats against each runtime's shipped hook contract
(Claude Code
PreToolUse, Codex[hooks]), with fixtures per runtime. - Package the hook so installation is a single documented step rather than a copied file,
and let it resolve nested policy the same way
validate.pydoes without duplication.
Harder problems (tracked in SPEC.md)
- Tamper resistance. Enforcement from outside the workspace — the only real answer to
an agent that can edit
agent-policy.yamland recompile. - Per-agent scoping and an allowlist mode for
exec