Skip to content

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.

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.

Each harness has its own permission settings:

team intent
never git push --force
never read .env
ask before terraform apply
never call github:delete_*
by hand
claude code.claude/settings.jsonpermissions: allow / ask / deny rules
codexconfig.tomlapproval policy + sandbox mode
cursor.cursor/cli.jsonpermissions: allow / deny lists
opencodeopencode.jsonpermission: allow / ask / deny

The 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.

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 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.

A policy is a capability, like a skill or a hook, with its own tuff.toml:

policies/infra-guardrails/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.

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:

tuff.toml
id = "infra-guardrails"
type = "policy"
deny git push --force
deny read .env, secrets/**
ask terraform apply
deny mcp github:delete_*
tuff add
claude.claude/settings.jsondeny → Bash(git push --force *)deny → Read(/**/.env)ask → Bash(terraform apply *)deny → mcp__github__delete_*
opencode.opencode/opencode.jsonpermission.bash → "git push --force *": "deny"permission.read → ".env": "deny"permission → "github_delete_*": "deny"
codex.codex/rules/tuff.rulesprefix_rule(["git", "push", "--force"], "forbidden")read rule and mcp pattern: recorded, not enforced
cursornot enforced yetinstall refused, so no rule is assumed

Installing the policy for Claude Code in a new project:

Terminal window
tuff add ./policies/infra-guardrails -a claude
Claude: 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 matched
Claude: 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 itself
Claude: 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 matched
installed infra-guardrails (claude) -> .claude/policies/infra-guardrails/policy.toml
compiled 5 permission rule(s) for infra-guardrails (claude) -> .claude/settings.json

The read rule lists two paths, so the four policy rules compile to five Claude Code rules:

.claude/settings.json
{
"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".

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:

Terminal window
tuff add ./policies/infra-guardrails -a codex
error: 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 form
hint: 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:

Terminal window
tuff add ./policies/infra-guardrails -a codex --accept-unenforced
Codex: 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 experimental
Codex: 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 refused
Codex: 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.lock
Codex: 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.lock
installed infra-guardrails (codex) -> .agents/policies/infra-guardrails/policy.toml
compiled 2 permission rule(s) for infra-guardrails (codex) -> .codex/rules/tuff.rules
.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:

Terminal window
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 form

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:

Terminal window
tuff add ./policies/infra-guardrails -a opencode
OpenCode: 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 it
OpenCode: 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 it
OpenCode: 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 it
installed infra-guardrails (opencode) -> .opencode/policies/infra-guardrails/policy.toml
compiled 6 permission rule(s) for infra-guardrails (opencode) -> .opencode/opencode.json
.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".

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
$ 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 yet

The 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.

Cursor and the shared Open Agents layout compile no policy rules. For them, tuff add installs nothing and lists every rule:

Terminal window
tuff add ./policies/infra-guardrails -a cursor
error: 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 yet
hint: 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 yet
hint: run 'tuff policy matrix' to see what each agent can enforce

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.

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.

Remove Read(/**/.env) from the settings file, then run:

Terminal window
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:

Terminal window
tuff update infra-guardrails -a claude --force
tuff check
Claude: 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 matched
Claude: 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 itself
Claude: 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 matched
installed infra-guardrails (claude) -> .claude/policies/infra-guardrails/policy.toml
compiled 5 permission rule(s) for infra-guardrails (claude) -> .claude/settings.json
✓ infra-guardrails policy claude ok

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:

.claude/settings.json
{
"model": "opus",
"permissions": {
"deny": [
"Bash(curl *)"
]
}
}
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.