Policies
A policy capability is a list of rules that narrow what a coding agent may do in a project: commands it must not run, files it must not read or edit, MCP tools it must not call, and actions it must ask a person about first. It is written once, and each agent the project uses enforces it in its own way, or Tuff says plainly that it cannot.
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 question is denied.
Format
Section titled “Format”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 [[policy.rules]] entry has an effect and exactly one subject.
| Field | Meaning |
|---|---|
effect |
"deny" refuses the action; "ask" requires a person to approve it first. |
command |
A shell command, as a prefix of its arguments, each argument its own string. ["git", "push", "--force"] matches git push --force and git push --force origin main. Arguments are literal words; * is not allowed. |
read |
Path patterns the agent must not read, such as ".env" or "secrets/**". |
edit |
Path patterns the agent must not edit or write. |
mcp |
An MCP tool as "server:tool", where either side may use *, such as "github:delete_*". |
reason |
Optional. Why the rule exists. |
Path patterns follow .gitignore rules as if the file sat at the project root: a pattern with a / at its start or middle, such as secrets/**, applies where it is written; one without, such as .env or *.pem, applies at any depth; and a trailing /, such as certs/, covers everything in that directory.
A policy installs no files. A rule with no subject, with two subjects, with an argument containing a space or *, or with a path that starts with / or ~ or climbs out with .. is refused when the policy is loaded, and so is a misspelt field.
A policy cannot allow anything
Section titled “A policy cannot allow anything”There is no "allow" effect, and a rule that uses one is refused. A policy is a capability like any other, so it can come from another team’s repository or a published pack. A policy that could grant permissions could quietly widen what an agent may do in every project that installs it. A policy that can only take permissions away can at worst be too strict, and too strict is something you notice. Permissions an agent should have belong in the harness’s own settings.
Claude Code
Section titled “Claude Code”Tuff compiles each rule into Claude Code’s own permission rules in .claude/settings.json, which Claude Code applies in the project without the workspace trust step, since deny and ask rules only restrict:
| Policy rule | Claude Code rule | List |
|---|---|---|
command = ["git", "push", "--force"] |
Bash(git push --force *) |
deny or ask |
read = [".env", "secrets/**"] |
Read(/**/.env), Read(/secrets/**) |
deny or ask |
edit = ["infra/prod/"] |
Edit(/infra/prod/**) |
deny or ask |
mcp = "github:delete_*" |
mcp__github__delete_* |
deny or ask |
What happens to your settings file
Section titled “What happens to your settings file”- Your existing settings stay. Tuff adds its rules next to the permission rules and settings already in
.claude/settings.json. A rule that is already there is not added again. - A broken file is not touched. If Tuff cannot read
.claude/settings.json, it stops before writing anything. - Tuff remembers which rules it added. Each rule is recorded in
tuff.lock, so later commands only touch those rules:
| Command | What it does with the policy’s rules |
|---|---|
tuff check |
Reports a rule that was deleted from the file by hand |
tuff update |
Removes rules the updated policy no longer has |
tuff delete |
Removes the rules this policy added, and nothing else |
How much each rule protects
Section titled “How much each rule protects”| Rule | Coverage | Why |
|---|---|---|
mcp |
full |
The rule names the MCP tool, so every call to it is caught |
command |
partial |
Only the command as Claude writes it is caught |
read, edit |
partial |
Only Claude’s own file tools and common shell commands are caught |
A command rule catches the command even inside a longer one, such as
cd x && git push --force. It does not catch the same action written a
different way:
- with the program’s full path, such as
/usr/bin/git push --force - inside another shell, such as
sh -c "git push --force" - with options before the subcommand, such as
git -C . push --force
A file rule covers Claude’s file tools and shell commands Claude Code
recognises, such as cat and sed. It does not stop a script or program
that opens the file itself.
This limit comes from Claude Code, not Tuff: Claude Code matches the text of a command, and its own documentation says command and file rules are not a security boundary.
Catching reworded commands
Section titled “Catching reworded commands”--runtime-hook also registers the policy hook as a PreToolUse hook on Bash in .claude/settings.json. The permission rules stay in place, and the hook checks each command again after reducing it to the programs it runs, so the three forms above are caught:
tuff add ./policies/infra-guardrails -a claude --runtime-hookThe hook is recorded in tuff.lock like a permission rule: tuff check reports it when it is edited or removed by hand, tuff update keeps it, and tuff delete removes it. A script or program that runs the command itself is still not seen. Checked in Claude Code 2.1.285, where /usr/bin/git push --force and sh -c "git -C . push --force" were refused with the rule’s reason while Bash was allowed.
Tuff compiles command rules into Codex’s command rules, in a file Tuff owns at .codex/rules/tuff.rules, and mcp rules into settings on the server’s table in .codex/config.toml:
| Policy rule | Codex rule |
|---|---|
effect = "deny", command = ["git", "push", "--force"] |
prefix_rule(pattern = ["git", "push", "--force"], decision = "forbidden") |
effect = "ask", command = ["terraform", "apply"] |
prefix_rule(pattern = ["terraform", "apply"], decision = "prompt") |
effect = "deny", mcp = "github:delete_repo" |
disabled_tools = ["delete_repo"] in [mcp_servers.github] |
effect = "ask", mcp = "github:merge_pull_request" |
approval_mode = "prompt" in [mcp_servers.github.tools.merge_pull_request] |
A rule’s reason becomes the rule’s justification, which Codex shows when it refuses the command. These mappings were checked against Codex CLI 0.154.0.
- Trust. Codex loads project rules only when the project is trusted. Until then the file is written but not applied.
- Experimental. Codex’s documentation labels rules experimental.
- Matching. Codex matches a command’s leading words, and splits a simple chain such as
git add . && git push --forceto check each command. A script with redirection,$(...), a variable assignment, a wildcard, or control flow is checked as one command, sogit push --force > push.logis not matched. A program run by absolute path, such as/usr/bin/git, may not be matched. - Ask without approvals. Where Codex never asks for approval, as in
codex execby default, apromptrule refuses the command. - MCP tools. Codex removes a tool in
disabled_toolsfrom the session, and asks before calling a tool whoseapproval_modeisprompt. Both settings take exact names, so anmcprule with*, such asgithub:delete_*, is not enforced in Codex. The server must already be in.codex/config.toml, installed withtuff add mcp <server> -a codexor written by hand, ortuff addrefuses the policy. Codex calls aprompttool without asking when its approval policy isneverand the sandbox allows full disk access or is off, as with--dangerously-bypass-approvals-and-sandbox; incodex execwith its default sandbox, the call is refused. - File rules. Codex has no project setting for file paths, so a
denyrule forreadoreditruns through the policy hook, registered onPreToolUsein.codex/hooks.json. Areadrule checks the files aBashcommand names on its command line, as arguments of programs such ascat,head,sed, andgrepor as<redirections. Aneditrule checks the files anapply_patchcall changes and the files aBashcommand writes, as>redirections or arguments of programs such astee,rm, andmv. A script or program that opens or writes a file itself is not seen. Codex runs a project’s hooks only in a trusted project and after they are approved with/hooks, and lets the call through if the hook fails. A Codex hook can refuse a call but cannot ask, so anaskrule forreadoreditis not enforced in Codex and installs only with--accept-unenforced.
tuff update of a server keeps the policy’s settings on its table, and tuff delete refuses to remove a server while an installed policy has settings on it. tuff check reports a compiled rule removed from either file by hand, and tuff delete of the policy removes its rules, and the rules file once no rules remain. To see how Codex reads a command rule:
codex execpolicy check --rules .codex/rules/tuff.rules -- git push --forceOpenCode
Section titled “OpenCode”Tuff compiles every kind of rule into OpenCode’s permission settings, in .opencode/opencode.json. Add the opencode agent first with tuff harness add opencode.
| Policy rule | OpenCode rule in permission |
|---|---|
command = ["git", "push", "--force"] |
"bash": {"git push --force *": "deny"} |
read = [".env"] |
"read": {".env": "deny", "*/.env": "deny"} |
read = ["secrets/**"] |
"read": {"secrets/**": "deny"} |
edit = ["infra/prod/"] |
"edit": {"infra/prod/*": "deny"} |
mcp = "github:delete_*" |
"github_delete_*": "deny" |
An ask rule is written with "ask" in place of "deny".
- Precedence. OpenCode applies the last permission rule that matches. It loads
.opencode/opencode.jsonafter the project’sopencode.json, and Tuff adds its rules after the rules already in.opencode/opencode.json,askbeforedeny. The policy’s rules therefore take precedence over the project’s own. InlineOPENCODE_CONFIG_CONTENT, managed configuration, and an agent’s ownpermissionsettings are applied later and can still override them. - Your files. Tuff keeps the keys, rules, and order already in
.opencode/opencode.json, and stops with an error if that file has a rule for the same pattern with a different action. It does not editopencode.jsonor.opencode/opencode.jsonc. - Commands. OpenCode checks each command it parses from the shell input, so
cd x && git push --forceandgit push --force > push.logare matched.sh -c "git push --force",/usr/bin/git push --force, andgit -C . push --forceare not. - Files. OpenCode matches the path relative to the project, so a pattern without a
/, such as.env, becomes two OpenCode patterns. Areadrule covers OpenCode’s read tool, and aneditrule its edit, write, and patch tools.grep,glob,list, and shell commands are separate permissions and are not covered. - MCP tools. A denied tool is hidden from the agent. OpenCode names a tool
<server>_<tool>, with characters other than letters, digits,_, and-replaced by_. - Ask.
opencode runrejects the request anaskrule raises, andopencode --autoapproves it.
opencode takes policies and MCP servers. Skills reach OpenCode through open-agents, and tuff init does not register opencode.
Cursor
Section titled “Cursor”Cursor has no project permission rules Tuff can write, so every rule runs through the policy hook, registered in .cursor/hooks.json with failClosed: true:
| Policy rule | Cursor hook |
|---|---|
command |
beforeShellExecution |
read |
beforeReadFile, and beforeShellExecution for the files a command names |
mcp |
beforeMCPExecution |
- Fail closed. With
failClosed, Cursor blocks the call when the hook crashes, times out, or cannot be started, so a machine withouttuffon itsPATHrefuses every shell command, file read, and MCP call the hook covers. - Ask. Cursor asks before a command or MCP call when the hook answers
ask.beforeReadFilecan only allow or deny, so anaskrule forreadis not enforced. - Edit rules. Cursor’s documentation does not describe where its
preToolUseinput carries the path of a file write, soeditrules are not enforced in Cursor. - Verification. The mapping follows Cursor’s hooks documentation and has not yet been checked in a running Cursor.
The policy hook
Section titled “The policy hook”Where an agent has no setting for a rule, or with --runtime-hook for Claude Code, Tuff registers tuff policy evaluate as a hook the agent runs before a tool call:
tuff policy evaluate --harness <agent> --policy <policy id>The agent passes the tool call as JSON on standard input. tuff policy evaluate finds the installed policy by walking up from the call’s working directory to the folder that holds <agent folder>/policies/<policy id>/policy.toml, checks the call against every rule, and answers in the agent’s own format: deny or ask with the rule and its reason, or nothing when no rule matches, so the agent decides as it would without the hook.
- Commands. A command is split the way a shell splits it, at
&&,||,;,|, and newlines, and each program it runs is checked. A path is reduced to the program name, so/usr/bin/gitisgit.sh -c,bash -lc,env,sudo,timeout,xargs,eval, and$(...)are unwrapped. Options before the subcommand are skipped, sogit -C . push --forcematches["git", "push", "--force"]. The rule’s later words may appear anywhere after the subcommand, sogit push origin main --forcematches too, and a short option cluster such as-rfmatches-frand-r -f. - Files. A path is judged relative to the project root, with the same
.gitignorereading as the rest of the policy. A path outside the project matches no rule. - Refusal. Input that is not JSON, and a policy that is no longer installed, are answered with
deny. - Speed. One evaluation took 4 ms at the median on an Apple silicon Mac with a release build, far below the agents’ hook timeouts.
- Requirements.
tuffmust be on thePATHof every machine and CI runner where the agent runs, and the installedtuffmust read the policy’s format.
tuff policy evaluate is meant to be run by the agent. To see what it answers, pipe a hook input into it:
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sh -c \"git push --force\""}}' \ | tuff policy evaluate --harness claude --policy infra-guardrailsWhat each agent can enforce
Section titled “What each agent can enforce”tuff policy matrixtuff policy matrix --jsonExample output, showing Claude Code and Cursor. The full output also lists Open Agents, whose rows read unsupported, Codex, whose rows read partial except ask for read and edit, and OpenCode, whose rows read like Claude Code’s:
┌─────────────┬────────┬─────────┬─────────────┬─────────────────────────────────────────────────────────────────────────────────┐│ ADAPTER │ EFFECT │ SUBJECT │ COVERAGE │ MECHANISM │├─────────────┼────────┼─────────┼─────────────┼─────────────────────────────────────────────────────────────────────────────────┤│ 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> ││ cursor │ deny │ command │ partial │ .cursor/hooks.json beforeShellExecution: tuff policy evaluate ││ cursor │ deny │ read │ partial │ .cursor/hooks.json beforeReadFile, beforeShellExecution: tuff policy evaluate ││ cursor │ deny │ edit │ unsupported │ ││ cursor │ deny │ mcp │ full │ .cursor/hooks.json beforeMCPExecution: tuff policy evaluate ││ cursor │ ask │ command │ partial │ .cursor/hooks.json beforeShellExecution: tuff policy evaluate ││ cursor │ ask │ read │ unsupported │ ││ cursor │ ask │ edit │ unsupported │ ││ cursor │ ask │ mcp │ full │ .cursor/hooks.json beforeMCPExecution: tuff policy evaluate │└─────────────┴────────┴─────────┴─────────────┴─────────────────────────────────────────────────────────────────────────────────┘The notes under the table give the caveat for each partial and unsupported row.
How to read it:
| Column | What it means |
|---|---|
ADAPTER |
The agent |
EFFECT |
deny or ask |
SUBJECT |
The kind of rule: command, read, edit, or mcp |
COVERAGE |
full (always enforced), partial (enforced with the limits in the notes), or unsupported (not enforced, so tuff add refuses the policy) |
MECHANISM |
What Tuff writes for that agent, such as a Claude Code permission rule |
The matrix has one row per agent, effect, and subject, with the same full, partial, and unsupported coverage the Hooks Specification uses for hooks, the mechanism a rule compiles to, and the caveat when coverage is partial. tuff add prints each partial caveat for the rules it installs, and refuses a policy for any selected agent that would not enforce one of its rules. Open Agents enforces nothing, Codex enforces every rule but ask for read and edit, Cursor enforces every rule but edit and ask for read, and Claude Code and OpenCode enforce every kind of rule.
Rules an agent does not enforce
Section titled “Rules an agent does not enforce”By default, tuff add refuses a policy when a selected agent does not enforce one of its rules, and installs nothing. --accept-unenforced installs the rules each agent enforces instead, and records the others in tuff.lock:
tuff add ./policies/infra-guardrails -a <agent> --accept-unenforcedtuff addprints each rule it did not install, with the agent and the reason.- An agent that enforces none of the policy’s rules still refuses the policy, with or without the flag.
tuff checkprints every recorded rule on each run, and exits 0 for them.tuff check --strictexits 1 while any rule is recorded, andtuff check --jsonlists them undergaps.tuff updaterecomputes the record without the flag. A rule the agent enforces in a newer Tuff, or a rule removed from the policy, leaves the record.
--accept-unenforced applies to tuff add <path>. Typed commands such as tuff add skill refuse it.
Where policies do not go
Section titled “Where policies do not go”A policy is not written into AGENTS.md, CLAUDE.md, or any other instruction file. Those are read by the model as advice and enforce nothing, which is exactly the gap a policy exists to close.