Skip to content

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.

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

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.

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

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

Terminal window
tuff add ./policies/infra-guardrails -a claude --runtime-hook

The 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 --force to check each command. A script with redirection, $(...), a variable assignment, a wildcard, or control flow is checked as one command, so git push --force > push.log is 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 exec by default, a prompt rule refuses the command.
  • MCP tools. Codex removes a tool in disabled_tools from the session, and asks before calling a tool whose approval_mode is prompt. Both settings take exact names, so an mcp rule with *, such as github:delete_*, is not enforced in Codex. The server must already be in .codex/config.toml, installed with tuff add mcp <server> -a codex or written by hand, or tuff add refuses the policy. Codex calls a prompt tool without asking when its approval policy is never and the sandbox allows full disk access or is off, as with --dangerously-bypass-approvals-and-sandbox; in codex exec with its default sandbox, the call is refused.
  • File rules. Codex has no project setting for file paths, so a deny rule for read or edit runs through the policy hook, registered on PreToolUse in .codex/hooks.json. A read rule checks the files a Bash command names on its command line, as arguments of programs such as cat, head, sed, and grep or as < redirections. An edit rule checks the files an apply_patch call changes and the files a Bash command writes, as > redirections or arguments of programs such as tee, rm, and mv. 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 an ask rule for read or edit is 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:

Terminal window
codex execpolicy check --rules .codex/rules/tuff.rules -- git push --force

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.json after the project’s opencode.json, and Tuff adds its rules after the rules already in .opencode/opencode.json, ask before deny. The policy’s rules therefore take precedence over the project’s own. Inline OPENCODE_CONFIG_CONTENT, managed configuration, and an agent’s own permission settings 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 edit opencode.json or .opencode/opencode.jsonc.
  • Commands. OpenCode checks each command it parses from the shell input, so cd x && git push --force and git push --force > push.log are matched. sh -c "git push --force", /usr/bin/git push --force, and git -C . push --force are not.
  • Files. OpenCode matches the path relative to the project, so a pattern without a /, such as .env, becomes two OpenCode patterns. A read rule covers OpenCode’s read tool, and an edit rule 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 run rejects the request an ask rule raises, and opencode --auto approves it.

opencode takes policies and MCP servers. Skills reach OpenCode through open-agents, and tuff init does not register opencode.

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 without tuff on its PATH refuses 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. beforeReadFile can only allow or deny, so an ask rule for read is not enforced.
  • Edit rules. Cursor’s documentation does not describe where its preToolUse input carries the path of a file write, so edit rules are not enforced in Cursor.
  • Verification. The mapping follows Cursor’s hooks documentation and has not yet been checked in a running Cursor.

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/git is git. sh -c, bash -lc, env, sudo, timeout, xargs, eval, and $(...) are unwrapped. Options before the subcommand are skipped, so git -C . push --force matches ["git", "push", "--force"]. The rule’s later words may appear anywhere after the subcommand, so git push origin main --force matches too, and a short option cluster such as -rf matches -fr and -r -f.
  • Files. A path is judged relative to the project root, with the same .gitignore reading 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. tuff must be on the PATH of every machine and CI runner where the agent runs, and the installed tuff must 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:

Terminal window
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sh -c \"git push --force\""}}' \
| tuff policy evaluate --harness claude --policy infra-guardrails
Terminal window
tuff policy matrix
tuff policy matrix --json

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

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:

Terminal window
tuff add ./policies/infra-guardrails -a <agent> --accept-unenforced
  • tuff add prints 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 check prints every recorded rule on each run, and exits 0 for them.
  • tuff check --strict exits 1 while any rule is recorded, and tuff check --json lists them under gaps.
  • tuff update recomputes 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.

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.