Permission gate
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.
Which harnesses reach the gate
| Harness | Mechanism | Reaches the gate | Timeline events | Grants |
|---|---|---|---|---|
| Claude | Permission prompt tool | Yes | Yes | Yes |
| Cursor | preToolUse hook | Yes | Yes | Yes |
| Codex | PreToolUse hook, trusted per launch | Yes | Yes | Yes |
| OpenCode | Rules written at launch | No | No | No |
| Amp | Rules written at launch | No | No | No |
| Gemini | Its most permissive unattended mode (--approval-mode yolo) | No | No | No |
| Antigravity | Its most permissive unattended mode (--dangerously-skip-permissions); a task with deny_tools or allowed_tools is refused | No | No | No |
Under a sandbox with network:, the Codex hook reaches Kraft through the sandbox's own route out: its command is Kraft's kraft shim, mounted read-only in every such container at /opt/kraft/bin, which asks the permission gate in the Kraft server as that session and denies whenever it gets no answer. The hook is trusted 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 a task whose image cannot vouch for the hook is refused. 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. A sandbox without network: has no route to Kraft, so there a Codex or Cursor task with something to enforce is refused instead of run unenforced, and the refusal says so. (Cursor ignores a proxy, so a sandbox with network: refuses it anyway.) Claude asks the same way: under network: it is launched with Kraft's own MCP server at http://kraft/mcp and no other, which answers its permission tool for that session alone. Without network: a sandboxed Claude task is refused whatever its policy, since its permission tool would be missing and the CLI exits at start. See Callbacks from a sandbox. OpenCode and Amp keep their mechanism.
OpenCode and Amp still enforce deny_tools and allowed_tools through
rules written at launch. See
How each harness runs unattended.
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_tools | Claude's mode | What reaches the gate |
|---|---|---|
| Not set | auto | Only calls Claude's classifier declines to settle. |
| Set | manual | Every call the built-in tool-name allowlist does not cover. |
Cursor
Cursor runs a preToolUse hook before every tool call, ahead of its own
--auto-review classifier. 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.
- 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.
- If a launch needs the hook and
.cursor/hooks.jsoncannot be read, Kraft refuses the launch and names the file. - The file never reaches a commit. Kraft excludes an untracked
.cursor/hooks.jsonthrough one marked line in the repository'sinfo/exclude, and a tracked one throughskip-worktreein that worktree's index. A skip-worktree file can make a rebase that touches it refuse to run.
The hook sees Cursor's tools under the names your policy uses:
| Cursor's tool | Checked as | Note |
|---|---|---|
Shell | Bash | |
Read | Read | |
Write | Write and Edit | Cursor creates and edits files with it. Denied if either is denied. Allowed under an allowlist only if both are listed. |
Delete | Delete | |
Grep | Grep | Also covers Cursor's glob. |
| Web fetch, web search | none | Never 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 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. When a Codex task's
policy has something to enforce (the same rule as Cursor), the launch adds two
-c flags, on the resume command line too:
hooks.PreToolUserunskraft admin permission-hook codex.hooks.statetrusts exactly that hook.
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.
- 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 bothWriteandEdit. - Web search runs on OpenAI's side and never reaches the hook. A Codex launch
whose policy would have to deny
WebSearchis 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 seesCommand 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 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. - An allowlist is
"*": "deny"followed byallowfor each listed tool. - Denying
Bashalso deniesexecute, OpenCode's code mode.
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.permissionsrules come before Amp's built-in ones, and the first match wins. - A denied tool gets a
rejectrule and everything else is left to Amp's built-ins. - Under an allowlist, each listed tool gets an
allowrule and a final*rule rejects the rest. An allowlisted tool is therefore allowed outright: Amp's own built-in asks (agit push, anrm -rf) no longer apply to it. - Denying
Bashrejectsshell_commandand its async and legacy forms. - Amp has no read, grep or glob tool of its own.
ReadorGrepindeny_toolsrefuses 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
editand Amp'sapply_patch, which each coverEditandWrite. - Nothing reaches the gate, so none of this appears as a
permission_decisionon the timeline. - Grants are not applied. A CLI rule like
git push *would also matchgit push x; rm -rf y, so Kraft writes no grant.
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. It checks these rows in order:
| The call | Prompt mode (Claude) | Enforce mode (Cursor and Codex hooks) |
|---|---|---|
Names a tool in deny_tools | Deny | Deny |
| Is an instance of one of the task's grants | Allow | Allow |
No layer set allowed_tools | Allow | No opinion: the CLI's own classifier or reviewer decides |
allowed_tools is set and names the tool | Allow | Allow |
allowed_tools is set and does not name it ([] names nothing) | Deny | Deny |
| The policy cannot be resolved (the task or its profile is gone) | Deny | Deny if the session is fail-closed, else no opinion |
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.
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 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 withoutBashit is denied. - an env prefix, or anything but
gitas the first word. - a git option before the subcommand other than
--no-pager,-P, or-csettinguser.nameoruser.email.-Cis refused because it would point git at another repository's config and hooks. - an option that runs a command of the caller's choosing:
--execand--receive-packon push,--exec/-xand--strategy/-son rebase, and any abbreviation of those long options.
A git-push grant is further held to one named remote and the work item's own
branch.
- Its first positional must be a plain remote name (letters, digits,
_,.,-, not starting with.,_or-), never a URL or path. - At least one refspec must follow, and every one must update the item's
branch and nothing else:
<branch>,refs/heads/<branch>, or<src>:either of those, such asHEAD:<branch>. A push that names no refspec, such as a baregit push, is not a grant: where it goes depends on git config the gate cannot see. - Kraft refuses
--delete/-d,--mirror,--all(and--branches),--prune,--tags,--follow-tags,--recurse-submodules,--repo,-o/--push-option, and a refspec with an empty<src>, which deletes. - Kraft refuses a plain
--force/-fand a forced+refspec.--force-with-leasestays allowed, because 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>.
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.
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.
| Harness | Limit |
|---|---|
| Cursor | A hook allow does not override --auto-review, so the classifier can still refuse a granted call. |
| Claude, no allowlist | The gate already allows every tool, so a grant adds nothing. |
Claude, allowlist without Bash | Claude launches without a Bash tool at all, so a granted git push never reaches the gate. |
| OpenCode, Amp | Grants 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.
Configuring the 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
Configuration for how the layers
combine.
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.