Permissions, plan mode & hooks

A Claude Code-style control plane for tool calls — permission modes, allow/deny/ask rules with fnmatch globs, read-only plan mode, human-in-the-loop callbacks, and blocking/rewriting hooks. New in v1.0.11.

3 min read
9 sections
Edit this page

New in v1.0.11, shipit_agent ships a fast, rule-based control plane for tool calls — the Claude Code permission layer, brought to a Python library. Every proposed tool call is checked against declarative allow / deny / ask rules and a mode before it runs. No LLM is required (unlike the verifier network, which is an LLM veto and complements this — the permission engine is the cheap first gate, the verifier is the smart second opinion).

TL;DRAgent(permission_mode="plan") makes a run read-only. Agent(permissions=PermissionEngine(deny=["bash", "*_delete"])) hard-blocks destructive tools. A denied call emits a tool_denied event and feeds the model a was NOT run tool message so it can recover.

Permission modes

Pass permission_mode= to Agent for the common cases — it mirrors Claude Code exactly:

ModeBehaviour
"default"Apply the allow/deny/ask rules; unmatched tools fall back to the engine's default_decision (allow).
"acceptEdits"Auto-allow file edit/write tools (write*, edit*, build_artifact); everything else still goes through the rules.
"plan"Read-only gate: only known read-only tools run. Every mutating or unknown tool is denied, so the agent must produce a plan instead of acting.
"bypass"Allow everything — an escape hatch; use with care.
python
from shipit_agent import Agent

# Auto-approve edits, gate everything else.
agent = Agent(llm=llm, tools=[...], permission_mode="acceptEdits")
result = agent.run("Refactor utils.py and add a docstring to each function.")

PermissionEngine — allow / deny / ask rules

For full control, pass a PermissionEngine. allow, deny, and ask are lists of tool-name globs (fnmatch*, ?, [...]) matched against the tool's name:

python
from shipit_agent import Agent, PermissionEngine

engine = PermissionEngine(
    mode="default",
    deny=["bash", "*_delete", "sql"],   # hard-block these, always
    allow=["read*", "*_search", "glob*"],  # auto-run these
    ask=["*_create", "*_update", "*send*"],  # pause for approval
)

agent = Agent(llm=llm, tools=[...], permissions=engine)

You can also pass a bare mode string or a kwargs dict — permissions is coerced into an engine for you:

python
Agent(permissions="plan")                       # mode string
Agent(permissions={"deny": ["bash"], "mode": "default"})  # kwargs dict

Precedence

A single, predictable order resolves every check:

bash
deny  >  mode logic  >  allow  >  ask  >  callback  >  default
  1. A deny glob match always wins — nothing overrides it.
  2. Mode logic runs next (bypass allows all; plan denies mutating tools; acceptEdits auto-allows edits).
  3. An explicit allow glob auto-runs the tool.
  4. An ask glob requires approval (routed through your callback if set).
  5. The callback (canUseTool-style) acts as a catch-all.
  6. Otherwise the engine's default_decision applies (allow by default).

A check returns a PermissionResult carrying a PermissionDecision (ALLOW / DENY / ASK), a reason, and optionally updated_arguments to rewrite the call before it runs.

Plan mode — read-only planning

agent.plan(prompt) is a one-call shortcut for read-only planning. It runs the agent under mode="plan", so it may call read-only tools (read, glob, grep, search, web fetch, …) but is denied every mutating tool. The model is told to write a step-by-step plan of what it would do, then stop.

python
agent = Agent(llm=llm, tools=[read_file, edit_file, bash])

plan = agent.plan("Migrate the auth module to the new session API.")
print(plan.output)   # a step-by-step plan; nothing was written or executed

# Reviewed the plan? Run it for real.
result = agent.run("Migrate the auth module to the new session API.")

Human-in-the-loop — permission_callback

Pass permission_callback(name, args) -> PermissionResult | None for programmatic approval. It's consulted for ask-matched tools and as the catch-all before the default. Return a PermissionResult to decide, or None to defer to the default decision:

python
from shipit_agent import Agent, PermissionResult, PermissionDecision

def approve(name, args):
    if name == "bash" and "rm" in args.get("command", ""):
        return PermissionResult(PermissionDecision.DENY, reason="no deletes")
    if name.endswith("_post"):
        ok = input(f"Run {name}({args})? [y/N] ").strip().lower() == "y"
        return PermissionResult(
            PermissionDecision.ALLOW if ok else PermissionDecision.DENY
        )
    return None   # defer

agent = Agent(llm=llm, tools=[...], permission_callback=approve)

Blocking & rewriting hooks

Hooks are no longer observe-only — a before_tool hook may return a decision to block a call or rewrite its arguments (Claude Code's PreToolUse). A hook can return None (allow, fully backward-compatible), a dict like {"decision": "deny"}, a bool (False → deny), or a PermissionResult:

python
from shipit_agent import Agent, AgentHooks, PermissionResult, PermissionDecision

hooks = AgentHooks()

@hooks.on_before_tool
def guard(name, args):
    # Block a destructive command.
    if name == "bash" and "rm -rf" in args.get("command", ""):
        return {"decision": "deny", "reason": "destructive command"}
    # Rewrite a call — scope a path, redact a secret — with updated_arguments.
    if name == "write_file" and args.get("path", "").startswith("/etc"):
        return PermissionResult(
            PermissionDecision.ALLOW,
            updated_arguments={**args, "path": "/tmp/sandbox" + args["path"]},
        )
    return None   # observe-only / allow

@hooks.on_user_prompt
def redact(prompt):
    # Return a string to rewrite the incoming prompt before the agent sees it.
    return prompt.replace("SECRET", "[redacted]")

agent = Agent(llm=llm, tools=[...], hooks=hooks)

Hooks and the permission engine are folded into one decision per call: a DENY from either side wins, then an ASK, otherwise the call is allowed (carrying the last updated_arguments rewrite).

What a denied call looks like

When a tool is blocked, the runtime does not silently drop it. It:

  • emits a tool_denied event (with the reason and decision), and
  • feeds the model a tool message — Tool 'bash' was NOT run — <reason> for a hard deny, or requires human approval — <reason> for an ask.

The model sees the denial in context and can adapt — pick a different approach, or explain why it's blocked — instead of hanging or crashing.

python
for event in agent.stream("Delete every log file."):
    if event.type == "tool_denied":
        print("blocked:", event.payload.get("reason"))

When to use

  • Plan-then-act workflowsagent.plan(...) to preview, review, then agent.run(...) to execute.
  • Untrusted prompts / autonomy guardrailsdeny=["bash", "*_delete"] to hard-block destructive tools no matter what the model decides.
  • Human-in-the-loop approvalask=[...] + a permission_callback to pause on sends, posts, and writes.
  • Argument rewriting — sandbox a path, redact a secret, or scope a query with updated_arguments from a before_tool hook.
  • Prompt redactionon_user_prompt to strip secrets before they ever reach the model.

See also