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): tool calls are evaluated deny rules → ask rules → allow rules → mode default, and unmatched calls fall through to human approval.
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 all three supported backends: a single Bash(...) rule covers claude_code's Bash, claw's bash/shell, and pi's bash; Edit(...) covers Edit/edit_file/file_edit; Read(...) covers Read/read_file; etc. (see canonicalToolName).
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. - 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.
All three honour the same permission.Policy, so a bot gets the same allow/ask/deny decision whichever gated backend executes it.
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: enforcement is implemented for
claw,claude_code, and pi RPC mode. Kimi, Grok, and legacy Codex do not consume the policy; do not use them whenpermission:is the safety boundary. - 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).
