Managing centralised guardrail policies for coding agents
A coding agent in a repository can run shell commands, read files, and call the tools its MCP servers expose. Teams usually have a short list of actions an agent must not take on its own, such as force-pushing to a shared branch, reading .env, running terraform apply without approval, or calling an MCP tool that deletes data.
Every command and file below is from tuffcli 0.12.0, which enforces policies in Claude Code, Codex, and OpenCode.
Where teams put guardrails today
Section titled “Where teams put guardrails today”Instruction files
Section titled “Instruction files”Teams often write these rules in CLAUDE.md or AGENTS.md, for example “never force push”. An instruction file is part of the prompt. The model reads the rule and decides whether to follow it, and nothing outside the model checks the tool call.
A harness permission rule is checked by the harness: each tool call is matched against the rule before it runs.
Harness permission settings
Section titled “Harness permission settings”Each harness has its own permission settings:
permissions: allow / ask / deny rulesapproval policy + sandbox modepermissions: allow / deny listspermission: allow / ask / denyThe settings differ in four ways:
- Vocabulary. Some harnesses match rules per tool and per command pattern. Others set an approval mode and a sandbox level instead of listing commands.
- Effects. Some harnesses support deny, ask, and allow. Others support only allow and deny, so a rule that asks a person first has no equivalent.
- Matching. A command rule matches a prefix of the words in one harness and a glob in another. Paths are relative in some settings files and absolute in others.
- Location. Some settings files live in the repository and are reviewed with the code. Others live in the user’s home directory.
A team that uses several agents writes the same rule once per harness. The copies can diverge, and an edit to one copy made while debugging is not reported anywhere.
Rules that are not enforced
Section titled “Rules that are not enforced”If a rule is written for a harness that cannot enforce it, or the harness matches it differently than the author expected, the agent performs the action and no error is shown.
A central policy
Section titled “A central policy”A central policy is declared once in the repository, and the harness settings are generated from it:
- Single source. The rules are reviewed in a pull request and versioned with the code.
- Shared. A platform or security team publishes a policy, and each project installs the same version.
- Coverage per harness. Each harness declares which rules it can enforce, and installation fails for a rule it cannot.
- Drift detection. The generated rules are tracked, and a removed rule fails CI.
How Tuff manages a policy
Section titled “How Tuff manages a policy”The policy file
Section titled “The policy file”A policy is a capability, like a skill or a hook, with its own tuff.toml:
id = "infra-guardrails"type = "policy"version = "1.0.0"description = "No force pushes, no secrets, and a human approves terraform apply."
[[policy.rules]]effect = "deny"command = ["git", "push", "--force"]reason = "Force pushes rewrite shared history."
[[policy.rules]]effect = "deny"read = [".env", "secrets/**"]
[[policy.rules]]effect = "ask"command = ["terraform", "apply"]
[[policy.rules]]effect = "deny"mcp = "github:delete_*"Each rule has an effect, deny or ask, and exactly one subject: a command, file paths the agent must not read, file paths it must not edit, or an MCP tool. Commands match by prefix and paths by pattern, which is what harness permission rules can match. Tuff refuses a policy when it loads if a rule has two subjects, a * inside a command, a path that climbs out with .., or a misspelt field.
Compiling to Claude Code permission rules
Section titled “Compiling to Claude Code permission rules”tuff add writes the policy into the harness’s own permission rules, and the harness enforces them in the same way as rules written by hand:
deny → Bash(git push --force *)deny → Read(/**/.env)ask → Bash(terraform apply *)deny → mcp__github__delete_*permission.bash → "git push --force *": "deny"permission.read → ".env": "deny"permission → "github_delete_*": "deny"prefix_rule(["git", "push", "--force"], "forbidden")read rule and mcp pattern: recorded, not enforcedinstall refused, so no rule is assumedInstalling the policy for Claude Code in a new project:
tuff add ./policies/infra-guardrails -a claudeClaude: rule 1 (deny command "git push --force") is enforced partially: matches the command as Claude writes it, including inside compound commands; the same program run another way, such as by absolute path, through sh -c, or as git -C . push, is not matchedClaude: rule 2 (deny read ".env", "secrets/**") is enforced partially: covers Claude's file tools and the shell commands Claude Code recognises, such as cat and sed, not a script or program that opens the file itselfClaude: rule 3 (ask command "terraform apply") is enforced partially: matches the command as Claude writes it, including inside compound commands; the same program run another way, such as by absolute path, through sh -c, or as git -C . push, is not matchedinstalled infra-guardrails (claude) -> .claude/policies/infra-guardrails/policy.tomlcompiled 5 permission rule(s) for infra-guardrails (claude) -> .claude/settings.jsonThe read rule lists two paths, so the four policy rules compile to five Claude Code rules:
{ "permissions": { "ask": [ "Bash(terraform apply *)" ], "deny": [ "Bash(git push --force *)", "Read(/**/.env)", "Read(/secrets/**)", "mcp__github__delete_*" ] }}tuff add prints a caveat for each rule that is only partly enforced. The command rule, for example, does not match sh -c "git push --force".
Compiling to Codex
Section titled “Compiling to Codex”Codex reads command rules from .codex/rules/. Tuff owns one file there and compiles the policy’s command rules into it. Codex has no project rule for file paths. It can disable an MCP tool or require approval for it in .codex/config.toml, but only by exact server and tool name, and this policy’s github:delete_* is a pattern. Two of the four rules have no Codex form, so tuff add refuses the policy rather than installing half of it:
tuff add ./policies/infra-guardrails -a codexerror: policy 'infra-guardrails' would not be enforced as written, so it was not installed: Codex: rule 2 (deny read ".env", "secrets/**") is not enforced: Codex rules match commands, not file paths, and Tuff does not compile Codex's sandbox permission profiles Codex: rule 4 (deny mcp "github:delete_*") is not enforced: Codex names MCP servers and tools exactly in disabled_tools and approval_mode, so a pattern with '*' has no Codex formhint: run 'tuff policy matrix' to see what each agent can enforce, or pass --accept-unenforced to install the rules each agent enforces and record the rest in tuff.lock--accept-unenforced installs the rules Codex does enforce and records the rest:
tuff add ./policies/infra-guardrails -a codex --accept-unenforcedCodex: rule 1 (deny command "git push --force") is enforced partially: matches the command's leading words, and each command of a simple chain joined by &&, ||, ; or |; a script with redirection, $(...), a variable assignment, a wildcard, or control flow is matched as one command and not caught, and a program run by absolute path such as /usr/bin/git may not be matched; Codex loads project rules only in a trusted project and labels rules experimentalCodex: rule 3 (ask command "terraform apply") is enforced partially: matches the command's leading words, and each command of a simple chain joined by &&, ||, ; or |; a script with redirection, $(...), a variable assignment, a wildcard, or control flow is matched as one command and not caught, and a program run by absolute path such as /usr/bin/git may not be matched; Codex loads project rules only in a trusted project and labels rules experimental; where Codex never asks for approval, as in codex exec by default, the command is refusedCodex: rule 2 (deny read ".env", "secrets/**") is not enforced, so it was not installed: Codex rules match commands, not file paths, and Tuff does not compile Codex's sandbox permission profiles; recorded in tuff.lockCodex: rule 4 (deny mcp "github:delete_*") is not enforced, so it was not installed: Codex names MCP servers and tools exactly in disabled_tools and approval_mode, so a pattern with '*' has no Codex form; recorded in tuff.lockinstalled infra-guardrails (codex) -> .agents/policies/infra-guardrails/policy.tomlcompiled 2 permission rule(s) for infra-guardrails (codex) -> .codex/rules/tuff.rules# Managed by Tuff: rules compiled from policy capabilities. Change the policy and run tuff update rather than editing this file.prefix_rule(pattern = ["git", "push", "--force"], decision = "forbidden", justification = "Force pushes rewrite shared history.")prefix_rule(pattern = ["terraform", "apply"], decision = "prompt")A rule’s reason becomes the justification Codex shows when it refuses. In Codex CLI 0.154.0, in a trusted project, asking the agent to run the command ends with rejected: Force pushes rewrite shared history. Codex loads project rules only when the project is trusted, and its own documentation labels rules experimental.
The two rules Codex cannot enforce are now recorded, not forgotten. tuff check prints them on every run and still passes; tuff check --strict exits non-zero while any remain, which is what a CI job uses:
tuff check✓ infra-guardrails policy codex ok! infra-guardrails policy codex rule 2 (deny read ".env", "secrets/**") is not enforced: Codex rules match commands, not file paths, and Tuff does not compile Codex's sandbox permission profiles! infra-guardrails policy codex rule 4 (deny mcp "github:delete_*") is not enforced: Codex names MCP servers and tools exactly in disabled_tools and approval_mode, so a pattern with '*' has no Codex formCompiling to OpenCode
Section titled “Compiling to OpenCode”OpenCode applies the last permission rule that matches, across every config file it loads, and it loads .opencode/opencode.json after the project’s own opencode.json. Tuff writes the policy’s rules into that later file, ask before deny, so a policy’s deny wins over a project’s allow without Tuff editing opencode.json at all. All four rules compile:
tuff add ./policies/infra-guardrails -a opencodeOpenCode: rule 1 (deny command "git push --force") is enforced partially: matches each command OpenCode parses from the shell input, including one with a redirection; the same program run through sh -c, by absolute path, or with options before the subcommand is not matched; OpenCode loads .opencode/opencode.json after the project's opencode.json, but inline OPENCODE_CONFIG_CONTENT, managed config, and an agent's own permission settings are applied after itOpenCode: rule 2 (deny read ".env", "secrets/**") is enforced partially: covers OpenCode's read tool; grep, glob, list, and shell commands are separate permissions and are not covered; OpenCode loads .opencode/opencode.json after the project's opencode.json, but inline OPENCODE_CONFIG_CONTENT, managed config, and an agent's own permission settings are applied after itOpenCode: rule 3 (ask command "terraform apply") is enforced partially: matches each command OpenCode parses from the shell input, including one with a redirection; the same program run through sh -c, by absolute path, or with options before the subcommand is not matched; opencode run rejects the request, and opencode --auto approves it; OpenCode loads .opencode/opencode.json after the project's opencode.json, but inline OPENCODE_CONFIG_CONTENT, managed config, and an agent's own permission settings are applied after itinstalled infra-guardrails (opencode) -> .opencode/policies/infra-guardrails/policy.tomlcompiled 6 permission rule(s) for infra-guardrails (opencode) -> .opencode/opencode.json{ "$schema": "https://opencode.ai/config.json", "permission": { "bash": { "terraform apply *": "ask", "git push --force *": "deny" }, "read": { ".env": "deny", "*/.env": "deny", "secrets/**": "deny" }, "github_delete_*": "deny" }}The read rule becomes three patterns, because OpenCode matches a path relative to the project: .env catches the file at the root, */.env catches it at any depth, and secrets/** is kept as written. An MCP rule becomes a tool name, since OpenCode names a tool <server>_<tool> and hides a denied one from the agent.
In OpenCode 1.18.15, in a project whose own opencode.json allowed everything, a live session refused git push --force and refused reading .env, and OpenCode’s rule list showed Tuff’s rules after the project’s own "*": "allow".
What each harness enforces
Section titled “What each harness enforces”tuff policy matrix lists each harness, effect, and subject with a coverage of full, partial, or unsupported, the terms the Hooks Specification uses, and the native rule each one compiles to:
$ tuff policy matrix┌─────────────┬────────┬─────────┬─────────────┬─────────────────────────────────────────────────────────────────────────────────┐│ ADAPTER │ EFFECT │ SUBJECT │ COVERAGE │ MECHANISM │├─────────────┼────────┼─────────┼─────────────┼─────────────────────────────────────────────────────────────────────────────────┤│ open-agents │ deny │ command │ unsupported │ ││ open-agents │ deny │ read │ unsupported │ ││ open-agents │ deny │ edit │ unsupported │ ││ open-agents │ deny │ mcp │ unsupported │ ││ open-agents │ ask │ command │ unsupported │ ││ open-agents │ ask │ read │ unsupported │ ││ open-agents │ ask │ edit │ unsupported │ ││ open-agents │ ask │ mcp │ unsupported │ ││ claude │ deny │ command │ partial │ permissions.deny Bash(<command> *) ││ claude │ deny │ read │ partial │ permissions.deny Read(<path>) ││ claude │ deny │ edit │ partial │ permissions.deny Edit(<path>) ││ claude │ deny │ mcp │ full │ permissions.deny mcp__<server>__<tool> ││ claude │ ask │ command │ partial │ permissions.ask Bash(<command> *) ││ claude │ ask │ read │ partial │ permissions.ask Read(<path>) ││ claude │ ask │ edit │ partial │ permissions.ask Edit(<path>) ││ claude │ ask │ mcp │ full │ permissions.ask mcp__<server>__<tool> ││ codex │ deny │ command │ partial │ .codex/rules/tuff.rules prefix_rule(decision = "forbidden") ││ codex │ deny │ read │ unsupported │ ││ codex │ deny │ edit │ unsupported │ ││ codex │ deny │ mcp │ partial │ .codex/config.toml [mcp_servers.<server>] disabled_tools ││ codex │ ask │ command │ partial │ .codex/rules/tuff.rules prefix_rule(decision = "prompt") ││ codex │ ask │ read │ unsupported │ ││ codex │ ask │ edit │ unsupported │ ││ codex │ ask │ mcp │ partial │ .codex/config.toml [mcp_servers.<server>.tools.<tool>] approval_mode = "prompt" ││ cursor │ deny │ command │ unsupported │ ││ cursor │ deny │ read │ unsupported │ ││ cursor │ deny │ edit │ unsupported │ ││ cursor │ deny │ mcp │ unsupported │ ││ cursor │ ask │ command │ unsupported │ ││ cursor │ ask │ read │ unsupported │ ││ cursor │ ask │ edit │ unsupported │ ││ cursor │ ask │ mcp │ unsupported │ ││ opencode │ deny │ command │ partial │ .opencode/opencode.json permission.bash "<command> *": "deny" ││ opencode │ deny │ read │ partial │ .opencode/opencode.json permission.read "<path>": "deny" ││ opencode │ deny │ edit │ partial │ .opencode/opencode.json permission.edit "<path>": "deny" ││ opencode │ deny │ mcp │ full │ .opencode/opencode.json permission "<server>_<tool>": "deny" ││ opencode │ ask │ command │ partial │ .opencode/opencode.json permission.bash "<command> *": "ask" ││ opencode │ ask │ read │ partial │ .opencode/opencode.json permission.read "<path>": "ask" ││ opencode │ ask │ edit │ partial │ .opencode/opencode.json permission.edit "<path>": "ask" ││ opencode │ ask │ mcp │ full │ .opencode/opencode.json permission "<server>_<tool>": "ask" │└─────────────┴────────┴─────────┴─────────────┴─────────────────────────────────────────────────────────────────────────────────┘The command then prints a note for every partial and unsupported row. The notes are where the limits live. Four of them, out of the eighteen it prints:
- claude: matches the command as Claude writes it, including inside compound commands; the same program run another way, such as by absolute path, through sh -c, or as git -C . push, is not matched- codex: matches the command's leading words, and each command of a simple chain joined by &&, ||, ; or |; a script with redirection, $(...), a variable assignment, a wildcard, or control flow is matched as one command and not caught, and a program run by absolute path such as /usr/bin/git may not be matched; Codex loads project rules only in a trusted project and labels rules experimental- opencode: covers OpenCode's read tool; grep, glob, list, and shell commands are separate permissions and are not covered; OpenCode loads .opencode/opencode.json after the project's opencode.json, but inline OPENCODE_CONFIG_CONTENT, managed config, and an agent's own permission settings are applied after it- cursor: Tuff does not compile policy rules for this agent yetThe recording below runs the policy guardrails example. Claude Code is asked for a value in .env and reads the file. tuff add then installs a policy that denies reading .env, and the same request is denied.
Harnesses that enforce nothing
Section titled “Harnesses that enforce nothing”Cursor and the shared Open Agents layout compile no policy rules. For them, tuff add installs nothing and lists every rule:
tuff add ./policies/infra-guardrails -a cursorerror: policy 'infra-guardrails' would not be enforced as written, so it was not installed: Cursor: rule 1 (deny command "git push --force") is not enforced: Tuff does not compile policy rules for this agent yet Cursor: rule 2 (deny read ".env", "secrets/**") is not enforced: Tuff does not compile policy rules for this agent yet Cursor: rule 3 (ask command "terraform apply") is not enforced: Tuff does not compile policy rules for this agent yet Cursor: rule 4 (deny mcp "github:delete_*") is not enforced: Tuff does not compile policy rules for this agent yethint: run 'tuff policy matrix' to see what each agent can enforce, or pass --accept-unenforced to install the rules each agent enforces and record the rest in tuff.lock--accept-unenforced does not help here, and says so. An agent that enforces none of a policy’s rules is refused either way, because installing would record a policy and write no rule:
error: policy 'infra-guardrails' was not installed: Cursor enforces none of its rules: Cursor: rule 1 (deny command "git push --force") is not enforced: Tuff does not compile policy rules for this agent yet Cursor: rule 2 (deny read ".env", "secrets/**") is not enforced: Tuff does not compile policy rules for this agent yet Cursor: rule 3 (ask command "terraform apply") is not enforced: Tuff does not compile policy rules for this agent yet Cursor: rule 4 (deny mcp "github:delete_*") is not enforced: Tuff does not compile policy rules for this agent yethint: run 'tuff policy matrix' to see what each agent can enforceNo allow rules
Section titled “No allow rules”A policy has no allow effect, and Tuff refuses a rule that uses one. Policies can come from another team’s repository or a published pack, and an allow rule in a shared policy would widen what an agent may do in every project that installs it. Permissions an agent needs go in the harness’s own settings.
Keeping the rules in place
Section titled “Keeping the rules in place”Existing settings
Section titled “Existing settings”Tuff adds its rules next to the existing contents of the harness’s file, .claude/settings.json, .codex/rules/tuff.rules, or .opencode/opencode.json, and records each rule it wrote in tuff.lock. It leaves rules it did not add unchanged, skips a rule that is already present, and stops before writing if the file cannot be read as its own format. In .opencode/opencode.json it also keeps the order of what is already there, since OpenCode reads rule order as precedence, and refuses rather than overwrite a rule of yours with the same pattern and a different action.
Detecting a removed rule
Section titled “Detecting a removed rule”Remove Read(/**/.env) from the settings file, then run:
tuff check✗ infra-guardrails policy claude modified (.claude/settings.json#permissions.deny)tuff check exits non-zero, so a CI job that runs it fails. The CI guide shows the GitHub Actions setup. To restore the rules:
tuff update infra-guardrails -a claude --forcetuff checkClaude: rule 1 (deny command "git push --force") is enforced partially: matches the command as Claude writes it, including inside compound commands; the same program run another way, such as by absolute path, through sh -c, or as git -C . push, is not matchedClaude: rule 2 (deny read ".env", "secrets/**") is enforced partially: covers Claude's file tools and the shell commands Claude Code recognises, such as cat and sed, not a script or program that opens the file itselfClaude: rule 3 (ask command "terraform apply") is enforced partially: matches the command as Claude writes it, including inside compound commands; the same program run another way, such as by absolute path, through sh -c, or as git -C . push, is not matchedinstalled infra-guardrails (claude) -> .claude/policies/infra-guardrails/policy.tomlcompiled 5 permission rule(s) for infra-guardrails (claude) -> .claude/settings.json✓ infra-guardrails policy claude okUpdating and deleting a policy
Section titled “Updating and deleting a policy”When a new version of a policy drops a rule, tuff update removes that rule from the settings file. tuff delete removes the rules the policy added and leaves the rest of the file. For a settings file that also had a hand-written Bash(curl *) deny rule and a model setting, deleting the policy leaves:
{ "model": "opus", "permissions": { "deny": [ "Bash(curl *)" ] }}Policies, instructions, and the sandbox
Section titled “Policies, instructions, and the sandbox”| Layer | Example | What it stops |
|---|---|---|
| Instructions | CLAUDE.md, AGENTS.md |
Nothing on its own. The model reads it as part of the prompt. |
| Harness policy | A Tuff policy compiled to permission rules | The agent’s own tool calls that match a rule, before they run. |
| Sandbox | The harness’s operating-system sandbox, containers | The action at the operating-system level, however it is written. |
Harness permission rules match the tool call the agent makes, so Tuff reports command and file rules as partial. Claude Code’s documentation states that these rules are not a security boundary. To block an action however it is written, also enable the harness’s sandbox. Tuff cannot enable it, because no file in the repository controls the sandbox.
Further reading
Section titled “Further reading”- The policies reference covers the format, the Claude Code mapping, and the coverage matrix.
- The policy guardrails example is the project from the recording.
- A capability pack bundles a policy with the skills, hooks, and MCP servers it applies to.