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.
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;DR —
Agent(permission_mode="plan")makes a run read-only.Agent(permissions=PermissionEngine(deny=["bash", "*_delete"]))hard-blocks destructive tools. A denied call emits atool_deniedevent and feeds the model awas NOT runtool message so it can recover.
Permission modes
Pass permission_mode= to Agent for the common cases — it mirrors Claude
Code exactly:
| Mode | Behaviour |
|---|---|
"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. |
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:
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:
Agent(permissions="plan") # mode string
Agent(permissions={"deny": ["bash"], "mode": "default"}) # kwargs dictPrecedence
A single, predictable order resolves every check:
deny > mode logic > allow > ask > callback > default- A deny glob match always wins — nothing overrides it.
- Mode logic runs next (
bypassallows all;plandenies mutating tools;acceptEditsauto-allows edits). - An explicit allow glob auto-runs the tool.
- An ask glob requires approval (routed through your callback if set).
- The callback (
canUseTool-style) acts as a catch-all. - Otherwise the engine's
default_decisionapplies (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.
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:
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:
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_deniedevent (with thereasonanddecision), and - feeds the model a tool message —
Tool 'bash' was NOT run — <reason>for a hard deny, orrequires human approval — <reason>for anask.
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.
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 workflows —
agent.plan(...)to preview, review, thenagent.run(...)to execute. - Untrusted prompts / autonomy guardrails —
deny=["bash", "*_delete"]to hard-block destructive tools no matter what the model decides. - Human-in-the-loop approval —
ask=[...]+ apermission_callbackto pause on sends, posts, and writes. - Argument rewriting — sandbox a path, redact a secret, or scope a query
with
updated_argumentsfrom abefore_toolhook. - Prompt redaction —
on_user_promptto strip secrets before they ever reach the model.
See also
- Hooks & middleware — the full hook lifecycle.
- Verifier network — the LLM-based second opinion.