Tool-permission gate (anti-prompt-injection)
iterion's permission gate restores Claude Code's default "ask before acting" posture to iterion workflows. One deterministic policy now drives claude_code, the in-process claw loop, and pi's embedded RPC extension.
Why
By default claude_code, claw, and pi nodes run effectively ungated (bypassPermissions on the CLIs): any tool the model decides to call executes unconditionally. That is convenient but it is also the posture a prompt-injection or "hypnosis" attack relies on — a poisoned web page, a malicious file, or a confused chain of reasoning can get the agent to exfiltrate a secret, curl an attacker, rm -rf a tree, or git push to a rogue remote, and nothing stops it.
The gate makes the operator's allow-list the frame of what's authorized, evaluated by deterministic code outside the model's controllable surface (pkg/backend/permission). Anything off-frame is denied, or surfaced to a human — exactly like Claude Code's canUseTool default. The model cannot talk its way past a rule, because the rules are not part of its context.
This mirrors the official Anthropic model (Agent SDK Configure permissions + Handle approvals and user input): workflow rules are evaluated deny rules → ask rules → allow rules → mode default, and unmatched calls fall through to human approval. An engine-owned grant created by an explicit operator approval is evaluated after a deny rule and before an ask rule, so the approved retry proceeds without changing the workflow DSL.
Modes
Set on the workflow block, per node, the CLI, or the environment. Opt-in: the default is off — existing bots are unchanged.
iterion permission: | Claude Code analog | Behavior |
|---|---|---|
off (default) | bypassPermissions | No gate (today's behavior). |
ask | default | allow-rules auto-approve; deny-rules hard-block; everything else pauses the run and surfaces the call to the human (resumable). |
deny | dontAsk | allow-rules approve; everything else is hard-denied with no pause — the policy boundary for headless / cloud / cron runs with no human attached. |
Rule syntax
Rules use Claude Code's syntax — a bare tool name matches any use, or a scoped Tool(pattern) matches an argument:
Rule lists use iterion's inline-array syntax (like capabilities:):
workflow main:
permission: ask
allow: ["Read(**)", "Edit(pkg/**)", "Bash(go test:*)", "Grep", "mcp__github__get_*"]
ask: ["Bash(git push:*)"]
deny: ["Bash(rm -rf:*)", "Read(.env*)", "WebFetch(domain:evil.example)"]Where:
Read(**)— read anything;Edit(pkg/**)— edit only underpkg/.Bash(go test:*)— anygo test …command (:*= prefix match).Grep(bare) — any grep;mcp__github__get_*— any github MCPget_tool.Bash(rm -rf:*)indeny:— neverrm -rf, even inaskmode.
A per-node override is the scalar mode only — permission: deny on an agent or judge node (the gate evaluates LLM-issued tool calls; a tool node's permission: is parsed but currently inert — see Status).
Matching semantics (pkg/backend/permission):
- Bash patterns match the
command;prefix:*is a prefix match, a bare wildcard*/**is a greedy match, no wildcard is exact. - Read / Edit / Write / NotebookEdit patterns match the file path;
pkg/**,*.goetc. work as gitignore-style globs. - WebFetch patterns match
domain:<host>,<host>, or the full URL. - Tool-name globs:
*(any tool) andmcp__<server>__*.
Cross-backend parity. The same rule gates the matching tool on every supported route: a single Bash(...) rule covers claude_code's Bash, claw's bash/shell, pi's bash, Grok's run_terminal_command, and Kimi's Bash; Edit(...) covers Edit/edit_file/file_edit/Grok's search_replace; Read(...) covers Read/read_file; etc. (see canonicalToolName).
Claude Code diagnostic bridge
diagnostic_shell remains a Claw-only alias in the normal tool catalogue. A node that explicitly declares it may, on Claude Code only, request one native Bash command through that alias's approval card. The bridge maps only a nonempty, single-line command, only when no explicit deny: ["Bash", …] rule matches. The card and its one-time grant are scoped to the command alone; model-authored descriptions do not broaden or break the retry. Multi-line commands and every node that did not declare the alias remain ordinary native Bash calls and follow the workflow's normal policy (typically deny).
The bridge is for bounded read diagnostics and source-nonmutating verification such as a targeted test or iterion validate. It is not a generic shell capability: do not use it for redirects, installs, network access, Git/source writes, or a command whose effect cannot be inspected from the single-line approval card.
Infrastructure exemption. iterion's own interaction/capability plumbing — ask_user, the board / control / watch MCP families — is never gated (or ask mode would pause on the very tool used to ask the human).
Precedence
Mode resolves with the same precedence as compress::
CLI --permission > node permission: > workflow permission: > ITERION_PERMISSION > offRule lists are additive: the workflow allow:/ask:/deny: lists plus any --permission-allow/--permission-ask/--permission-deny run-level rules.
The studio Launch dialog captions the permission select with the resolved mode and the level it came from ("effective: ask · from workflow") — see settings-precedence.md.
CLI
iterion run bot.bot --permission ask \
--permission-allow 'Read(**)' --permission-allow 'Bash(go test:*)' \
--permission-deny 'Bash(rm -rf:*)'
# Headless hard boundary (no human to pause for):
iterion run bot.bot --permission deny --permission-allow 'Read(**)'Environment: ITERION_PERMISSION=ask|deny|off.
How it works
The resolved permission.Policy is carried on delegate.Task.Permission and evaluated by each gated backend before every tool runs:
- claw —
executeToolsDirect(pkg/backend/model/generation.go) evaluates the policy beforegt.Execute. Allow → execute; Deny → a syntheticisErrortool_result the model adapts to; Ask → the loop aborts withdelegate.ErrAskUserso the run pauses. Sandboxed claw enforces the same gate: the policy crosses the IPC as a pre-taskpermission_policyenvelope (permission.PolicyConfig— raw rule strings re-parsed in-container by the same parser), and the__claw-runner's own tool loop applies it to local builtins and proxied tools alike. The pre-task position makes a mixed-version fleet fail CLOSED: an older runner fatals on the unknown envelope instead of running the node ungated. What cannot cross is an Ask decision — nothing inside the container can pause the parent run — so a policy that can produce one (modeask, or any explicitask:rule, which outranks modedeny) is refused loudly at dispatch, and C136 warns about the coupling at compile time. - claude_code — a broad PreToolUse hook (
wirePermissionHookin claude_code.go) evaluates the policy. Under the always-onbypassPermissions, PreToolUse hooks still run and adenydecision still blocks the tool (Agent SDK order: hooks run first), so no--permission-modechange is needed. Ask reuses theask_usercapture-and-pause path. - pi (RPC mode) — the embedded iterion extension intercepts tool calls and asks Go to evaluate
permission.evaluateover the control channel. Ask unwinds the turn as the samedelegate.ErrAskUserpause. Pi print mode has no control channel and refuses a permission-gated node rather than running it unguarded. - kimi (
denyonly) — iterion creates a private shadowKIMI_CODE_HOMEfor each invocation, links the operator's credentials and config into it, and appends aPreToolUsehook. The hook subprocess rebuilds the policy and evaluates it with the same Go implementation. A deny is returned in kimi's nativehookSpecificOutputshape. The real~/.kimi-codeis never modified. - grok (
denyonly) — the same shadow-home design usesGROK_HOMEplus a globalhooks/iterion-permission.json, and the deny is spelled in grok's native{"decision":"deny","reason":…}shape. It holds under the--permission-mode bypassPermissions --always-approveflags iterion always passes, because grok's authorization pipeline runsPreToolUsehooks first and always-approve only short-circuits the checks after them.
The policy travels by value, and the shadow home lives outside the workspace. Both matter for the same reason: the hook subprocess is the gate's entire authority on these backends, and the agent it gates runs as the same OS user. So the serialised PolicyConfig is passed base64-encoded in the hook's own argv — which both CLIs freeze when the session starts — instead of as a file the hook would re-read on every tool call; and the shadow home is created under the OS temp dir rather than <workspace>/.iterion/<backend>, which is where a repo-scoped Edit(**) / Write(**) allow rule would reach. Without those two properties, one allowed write of {"mode":"off"} would disarm the gate for the rest of the node — an escalation the in-process claude_code and claw gates cannot have, since their policy never leaves iterion's memory. A policy the hook cannot decode fails closed, and so does a panic: the hook recovers and still emits a deny, because a process that dies with empty stdout is read as allow by both CLIs — which would turn any future bug in the evaluator into a silent gate bypass.
sandbox: none is required today, and C136 says so at compile time. The hook binary and the CLI home are host-side, so a sandboxed run cannot reach them and the node is refused before the CLI starts. Since the shipped default is sandbox: auto, a gated grok/kimi node with no sandbox: block would otherwise compile clean and die mid-run — so the compiler warns (C136) rather than letting the operator discover the coupling after launch. --sandbox none and ITERION_SANDBOX_DEFAULT=none satisfy it too, which is why C136 warns instead of rejecting. Lifting the restriction means carrying the shadow home and the hook binary into the container; until then the refusal is the honest answer.
The hook binary must live outside the workspace. It is the third thing the gated agent must not be able to reach, and the sharpest: unlike the frozen argv it is re-executed on every tool call, and both CLIs fail open on a spawn failure — so corrupting the file, not replacing it with a working one, is enough. proc.LocateIterionBinary resolves next to os.Executable() first, which in the repo-root shape (./iterion run …, or task studio:dev pinning ITERION_BIN to a freshly built ./iterion) is inside the very workspace being gated. The path is absolutised before that check and before it is frozen into the hook argv: a relative ITERION_BIN used to defeat pathInsideCheckout (which cannot relate an absolute workspace to a relative path) and would then resolve against the CLI's cwd — the gated workspace — at spawn time. iterion refuses an in-workspace binary and points at ITERION_BIN on a stable install path outside the repo.
The hook process must not read the workspace either. Both CLIs spawn it with cwd = the project and re-execute it on every tool call; a timeout is an ALLOW. So __permission-hook skips loadDotEnvFromCwd and errtrack.Init (an unbounded .env the agent can write would be enough to blow grok's hook timeout, and a SENTRY_DSN line in the same file would point the gate at an endpoint the operator did not choose), and the registered command cds into the shadow home before iterion starts.
Windows is refused. The hook command is quoted for a POSIX shell; Node-based CLIs on Windows run that string through cmd.exe, which does not treat ' as quoting. A spawn failure is an ALLOW on both CLIs, so enabling Developer Mode for the symlink half of the seam would still leave the node ungated. Use permission: off, or a backend with an in-process gate (claude_code, claw).
Neither external hook is admitted on a declaration: each earned its entry in gateEnforcingModes with a live denial where a filesystem sentinel — not model prose — is the oracle (e2e/live_feat_permission_{kimi,grok}_test.go). Delete those tests and the entry becomes the lie C176 exists to prevent.
Every hook honours the same permission.Policy; protocol adapters only decode the native event and spell the native verdict.
Status / limitations
offanddenymodes, and explicitallow:/deny:rules in any mode, are fully deterministic and need no human — the complete anti-injection boundary for headless and cloud runs.askmode pauses the run (paused_waiting_human) and surfaces the off-policy call to the operator, so nothing off-policy ever executes silently. To resolve the pause, the operator just answers the approval question withallow,allow always, ordeny— on any of the three gated backends. The pause carries a structured marker (tool + input- rule); the runtime maps the answer to a grant rule (
allow= argument-scoped,allow always= whole-tool) and feeds it back into the resolved policy, so the agent's re-issued call passes the gate and executes after the generic[PERMISSION GRANTED]resume reminder.denyrefuses the call and the agent adapts. The--permission-allowflags onresumeremain available for scripted/headless approval.
- rule); the runtime maps the answer to a grant rule (
- The marker also lets a
permission: asknode pause without needinginteraction:set — the gate is its own reason to pause. - Backend scope:
claw,claude_code, and pi RPC supportaskanddeny. Kimi and Grok supportdenyonly;ask, adenypolicy containing explicitask:rules, and sandboxed guarded runs on either are refused before the CLI is launched. Codex has no permission seam and refuses any enabled gate. - Primary routes are screened too. C176 applies to the node's effective primary backend as well as authored and run-level fallbacks, so an unsupported backend can no longer run a declared gate silently.
- Node scope: the gate evaluates the tool calls an agent/judge LLM makes. A
toolnode (a direct, deterministic shell command, no LLM) is the action itself and is governed by the Verified Action quad (goal/postcondition/policy/recovery), not this gate — so apermission:mode on atoolnode is currently reserved (parsed, not yet enforced).
See also
pkg/backend/permission/— the matcher + Policy (single source of truth)docs/plugins.md— the sibling opt-incompress:field this mirrors- Diagnostics: C110 (invalid permission mode), C111 (rules declared but gate off), C112 (tool-node
permission:— parsed but not enforced).
