v2.0.0next
Reference

Permission gate

What Kraft's permission gate does per harness, how it decides, and the keys that configure it.

Kraft's permission gate answers a worker's tool-permission request from the task's own policy. Each answer, allow or deny and why, lands on the work item's timeline. For the reasoning behind this design, see Why a permission gate.

The gate enforces whatever allowed_tools, deny_tools and grants resolve to at the task's scope. Set them like any other policy field: see the allowed_tools, deny_tools and grants rows in policy.yaml for how the layers combine.

Which harnesses reach the gate

HarnessMechanismReaches the gate
ClaudePermission prompt toolYes
CursorpreToolUse hookYes
CodexPreToolUse hook, trusted per launchYes
OpenCodeRules written at launchNo
AmpRules written at launchNo
GeminiIts most permissive unattended mode (--approval-mode yolo)No
AntigravityIts most permissive unattended mode (--dangerously-skip-permissions)No

A harness that reaches the gate also logs a permission_decision event for each call it decides, and takes grants. A harness that does not reach it has neither.

Gemini and Antigravity refuse a task with deny_tools or allowed_tools. OpenCode and Amp still enforce those lists through rules written at launch. Unattended runs shows the mode each harness runs in.

How the gate decides

The gate answers from the resolved policy of the task the calling session is running: the same allowed_tools, deny_tools and grants its launch resolved. Claude asks in prompt mode. The Cursor and Codex hooks ask in enforce mode.

If the policy cannot be resolved (the task, its harness profile or its agent profile is gone), that decides the call first: Deny, or, for a Cursor or Codex session that is not fail-closed, no opinion. Otherwise the gate checks these rows in order:

The callPrompt mode (Claude)Enforce mode (Cursor and Codex hooks)
Cursor only: touches .cursor/hooks.json or .cursorNot applicableDeny
Names a tool in deny_toolsDenyDeny
Is an instance of one of the task's grantsAllowAllow
No layer set allowed_toolsAllowNo opinion: the CLI's own classifier or reviewer decides
allowed_tools is set and names the toolAllowAllow
allowed_tools is set and does not name it ([] names nothing)DenyDeny

A deny is a deny on every harness. An allow is not always final. On Cursor, a hook allow does not override --auto-review, so Cursor's classifier can still refuse a call the gate allowed. Whether a Codex hook allow outranks Codex's reviewer is not guaranteed.

Claude

Kraft launches Claude with its own MCP tool, mcp__kraft__permission_request, as the permission prompt tool. Claude calls it for any tool call its own classifier does not settle, and Kraft answers from the task's policy.

What reaches the gate depends on permission_mode:

Task's allowed_toolsClaude's modeWhat reaches the gate
Not setautoOnly calls Claude's classifier declines to settle.
SetmanualEvery call the built-in tool-name allowlist does not cover.

Under an allowed_tools list, the launch also passes Claude's --tools with the list's built-in tool names. That removes every built-in tool the list does not name. An mcp__ tool in the list is not passed to --tools, because that flag does not govern MCP tools.

Every Claude launch also denies Monitor, whatever the policy says: it is never legitimate in a one-shot claude -p process, because there is no later turn for it to resume into.

Cursor

Cursor runs a preToolUse hook before every tool call, ahead of its own --auto-review classifier.

The Cursor hook

When a Cursor task's policy has something to enforce, Kraft writes an entry into the worktree's .cursor/hooks.json that runs kraft admin permission-hook cursor. Something to enforce means an allowed_tools list, any deny_tools, or a grant other than git-commit. With nothing to enforce, Kraft writes no hook.

The hook sees Cursor's tools under the names your policy uses:

Cursor's toolChecked asNote
ShellBash
ReadRead
WriteWrite and EditCursor creates and edits files with it. Denied if either is denied. Allowed under an allowlist only if both are listed.
DeleteDelete
GrepGrepAlso covers Cursor's glob.
Web fetch, web searchnoneNever reach the hook.

Kraft cannot deny a web fetch or web search on Cursor. A Cursor launch whose policy would have to deny one is refused, rather than run with that part of its policy unenforced. That means WebFetch or WebSearch in deny_tools, or an allowed_tools list that does not name both.

The hook file

  • Your repository's own hooks in that file stay. Kraft's entry sits beside them.
  • The entry stays for the worktree's life and is the same for every launch.
  • The entry runs Kraft's own Python with -I. Its import path then holds neither the worktree nor a PYTHONPATH from the worker's environment. A file the worker writes into the worktree, such as an argcomplete.py, cannot run in place of Kraft's code and answer the hook.
  • If a launch needs the hook and .cursor/hooks.json cannot be read, Kraft refuses the launch and names the file.
  • The file never reaches a commit. Kraft excludes an untracked .cursor/hooks.json through one marked line in the repository's info/exclude, and a tracked one through skip-worktree in that worktree's index. A skip-worktree file can make a rebase that touches it refuse to run.

The worker may not change the file. The gate denies a Write or Delete of .cursor/hooks.json or of .cursor itself, in any letter case or through a symlink in the worktree. It also denies a Shell command that names .cursor or hooks.json, whatever the policy allows.

This is a check on the call's text: a script can still reach the file. Each launch with policy writes Kraft's entry back, so a worker that removes it works ungated for the rest of its own session at most. If that matters, deny Shell or leave it off the allowlist.

Failing closed

The hook asks the gate in enforce mode. Under an allowed_tools list, the launch sets KRAFT_PERMISSION_FAIL_CLOSED=1 in the worker's environment, and the hook fails closed: if Kraft is unreachable, the payload is unreadable or the policy cannot be resolved, the call is denied. Without an allowlist, any of those is no opinion, and Cursor's classifier decides as if there were no hook.

Kraft's entry also sets failClosed: true, so a hook that crashes or takes longer than its 10-second timeout denies the call, with or without an allowlist: Cursor would otherwise allow it.

Codex

Codex runs a PreToolUse hook before each tool call.

The Codex hook

When a Codex task's policy has something to enforce (the same rule as Cursor), the launch adds three -c flags, on the resume command line too:

  • hooks.PreToolUse runs kraft admin permission-hook codex.
  • features.hooks=true switches hooks on.
  • hooks.state trusts exactly that hook and marks it enabled.

Kraft never passes --dangerously-bypass-hook-trust, since that also trusts every hook a repository ships. Kraft writes nothing to your ~/.codex or to the worktree. If Codex cannot list the hook, does not trust it, or does not answer within 15 seconds, Kraft refuses the launch.

What the Codex hook sees and returns

  • The hook also fires for Codex's own background agents. A call whose working directory is inside Codex's home ($CODEX_HOME, else ~/.codex) gets no opinion and never reaches the gate. Every other call is asked, wherever it runs.
  • Codex calls its shell Bash. apply_patch, its one tool for creating and editing files, is checked as both Write and Edit.
  • Web search runs on OpenAI's side and never reaches the hook. A Codex launch whose policy would have to deny WebSearch is refused.
  • Fail-closed under an allowlist works as it does for Cursor.
  • No opinion is {}, which leaves the decision to Codex's own reviewer. A deny blocks the call, and the agent sees Command blocked by PreToolUse hook: Kraft: <reason>.

OpenCode and Amp: rules written at launch

OpenCode and Amp get no per-call hook. Kraft writes the task's deny_tools and allowed_tools into the CLI's own permission configuration for that one launch, and the CLI enforces them itself. With neither set, Kraft writes nothing.

OpenCode

OpenCode gets the rules as a permission block in OPENCODE_CONFIG_CONTENT, and runs as opencode run --standalone, because only a standalone run reads that block.

  • A denied tool is deny; for bash it is {"?*": "deny"}, which keeps the tool listed and still denies every call.
  • An allowlist is "*": "deny", with external_directory and doom_loop put back to ask, followed by allow for each listed tool.
  • Denying Bash also denies execute, OpenCode's code mode.

Amp

Amp gets a settings file of the launch's own, $KRAFT_HOME/run/harness-config/amp/<session>.json, passed with --settings-file. For that run it replaces ~/.config/amp/settings.json, which Kraft never reads or writes. Your Amp login still works.

  • Its amp.permissions rules come before Amp's built-in ones, and the first match wins.
  • A denied tool gets a reject rule and everything else is left to Amp's built-ins.
  • Under an allowlist, each listed tool gets an allow rule and a final * rule rejects the rest. An allowlisted tool is therefore allowed outright: Amp's own built-in asks (a git push, an rm -rf) no longer apply to it.
  • Denying Bash rejects shell_command and its async and legacy forms.
  • Amp has no read, grep or glob tool of its own. Read or Grep in deny_tools refuses an Amp launch, and in an allowlist grants nothing.

On both

  • A denied tool name that no tool on that CLI maps to refuses the launch and names the tool.
  • An allowlisted name the CLI has no tool for grants nothing. Every tool not listed is denied anyway.
  • A CLI tool that covers two policy names is denied if either is denied, and allowed under an allowlist only if both are listed. This applies to OpenCode's edit and Amp's apply_patch, which each cover Edit and Write.
  • Nothing reaches the gate, so none of this appears as a permission_decision on the timeline.
  • Grants are not applied. A CLI rule like git push * would also match git push x; rm -rf y, so Kraft writes no grant.

Grants

A grant is a named operation the gate allows a task even outside its allowed_tools: git-commit, git-rebase or git-push. It matches a Bash call only when the command is exactly one plain git invocation of that subcommand. Otherwise the call falls through to the rest of the decision table.

A grant does not stop git's own hooks. A commit or push under a grant still runs the repository's hooks, including a core.hooksPath inside the tree (.husky/, say), which the agent can edit.

What no grant covers

A command is refused as a grant if it has:

  • a shell operator, substitution, redirection, subshell, brace or glob expansion, backslash, ! or a newline. A commit message with $ or ! in it, a multi-line one, or one written through a heredoc is not granted. With no allowlist that leaves it to the CLI's classifier. Under an allowlist without Bash it is denied.
  • an env prefix, or anything but git as the first word.
  • a git option before the subcommand other than --no-pager, -P, or -c setting user.name or user.email. -C is refused because it would point git at another repository's config and hooks.
  • an option that runs a command of the caller's choosing: --exec and --receive-pack on push, --exec/-x and --strategy/-s on rebase, and any abbreviation of those long options.

What a git-push grant allows

A git-push grant is further held to one named remote and the work item's own branch.

The pushGranted
Names a plain remote, then at least one refspec. Every refspec updates the item's branch and nothing else: <branch>, refs/heads/<branch>, or <src>: either of those, such as HEAD:<branch>Yes
Names a remote that is not a plain name: a URL, a path, or a name with characters outside letters, digits, _, . and -, or one starting with ., _ or -No
Names no refspec, such as a bare git push: where it goes depends on git config the gate cannot seeNo
Uses --delete/-d, --mirror, --all (and --branches), --prune, --tags, --follow-tags, --recurse-submodules, --repo or -o/--push-option, or a refspec with an empty <src>, which deletesNo
Uses a plain --force/-f or a forced +refspecNo
Uses --force-with-leaseYes: an escalation that rebased the branch has to force-push it

An escalation turn is told the command: git push --force-with-lease origin HEAD:<branch>.

Where a grant takes effect

A grant is the gate's decision, a logged allow on the item's timeline. Whether the CLI then runs the call is the CLI's own business.

HarnessLimit
CursorA hook allow does not override --auto-review, so the classifier can still refuse a granted call.
Claude, no allowlistThe gate already allows every tool, so a grant adds nothing.
Claude, allowlist without BashClaude launches without a Bash tool at all, so a granted git push never reaches the gate.
OpenCode, AmpGrants are not applied.

How grants combine

Grants accumulate down the layers (repository policy:, chain, node, step, task), as deny_tools does. A workspace grants only what every repository in it grants. A work item's own override, and a retry's, can drop a grant but never add one.

An escalation turn holds its node's grants plus defaults.escalation_grants from policy.yaml. Unset, that is all three grants. Set it to a shorter list, or [], to grant escalations less. A gate's reviewer gets no such default. A chain task's git-commit already comes with its launch.

Under a sandbox

A sandbox changes how a harness reaches the gate:

HarnessWith network:With unrestricted_network: true instead
ClaudeKraft launches it with its own MCP server at http://kraft/mcp and no other. That server answers the permission tool for that session alone.Kraft refuses the task whatever its policy: the permission tool would be missing and the CLI exits at start.
CodexThe hook reaches Kraft through the sandbox's own route out, as below.Kraft refuses a task with something to enforce, and the refusal says so: there is no route to Kraft.
CursorKraft refuses the task: Cursor ignores a proxy.Kraft refuses a task with something to enforce, and the refusal says so: there is no route to Kraft.

A sandbox with neither network: nor unrestricted_network: true is refused for every harness. See Callbacks from a sandbox.

The Codex hook under network:

  • The hook's command is Kraft's kraft shim, mounted read-only in every such container at /opt/kraft/bin.
  • The shim asks the permission gate in the Kraft server as that session, and denies the call whenever it gets no answer.
  • Kraft trusts the hook by the hash the image's own codex gives, asked in a short container with no network.
  • Codex skips an untrusted hook without a word and runs the call, so Kraft refuses a task whose image cannot vouch for the hook.
  • The launch also switches hooks on and marks Kraft's enabled, so a Codex config a worker writes into its home directory cannot turn the hook off.

Reading decisions

Kraft appends every decision to the work item's timeline as a permission_decision event. The event carries:

  • the tool name, and the session and node it came from;
  • the decision and the reason;
  • the grant that allowed it, if one did;
  • for a hook, the harness and the CLI's own tool name (harness: cursor, cli_tool: Shell).

No opinion is not a decision, so an enforce-mode call left to Cursor's classifier logs nothing. Read the events with:

kraft view events ID --type permission_decision

Add -f to watch a live item.

Copyright © 2026