"""Pattern 7: deferred approval. A PreToolUse hook pauses the run at a risky command; the process ends with
stop_reason=tool_deferred; later, with approval recorded, the session is resumed and the same hook allows the call."""
import asyncio
import re
import shutil
import tempfile
from pathlib import Path
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher, ResultMessage, query
from demo_util import PRISTINE, PROJECT
WORK = Path(tempfile.mkdtemp(prefix="deploy-")) / "project"
shutil.copytree(PRISTINE, WORK)
(WORK / "deploy.sh").write_text("#!/bin/sh\necho 'deploying version 1.2.3 to production'\n")
(WORK / "deploy.sh").chmod(0o755)
APPROVALS: set[str] = set() # stands in for your approvals table (ticket system, Slack button, ...)
PENDING: dict[str, str] = {} # session_id -> command awaiting approval
SEGMENT = re.compile(r"&&|\|\||;|\|") # split a shell line into its commands
def runs_deploy(cmd: str) -> bool:
"""True when deploy.sh is EXECUTED (first word of any segment), not merely read with cat/head."""
return any(seg.split()[0].endswith("deploy.sh") for seg in SEGMENT.split(cmd) if seg.split())
async def gate_deploys(input_data, tool_use_id, context):
cmd = input_data["tool_input"].get("command", "")
if input_data["tool_name"] == "Bash" and runs_deploy(cmd):
if cmd in APPROVALS: # (1) on resume: a human said yes
print(f" [hook] approval found for {cmd!r}: allow")
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow",
"updatedInput": input_data["tool_input"]}}
PENDING[input_data["session_id"]] = cmd # (2) first time: record and pause
print(f" [hook] {cmd!r} needs human approval: defer")
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "defer"}}
return {}
OPTS = dict(cwd=str(WORK), allowed_tools=["Read", "Bash(./deploy.sh*)", "Bash(cat *)", "Bash(ls *)"], max_turns=6,
setting_sources=[], hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[gate_deploys])]})
async def run_until_not_deferred(prompt, session_id=None):
"""Run; whenever the run pauses on a deferred tool, 'collect approval' and resume the same session."""
for attempt in range(4):
last = None
async for m in query(prompt=prompt, options=ClaudeAgentOptions(resume=session_id, **OPTS)):
if isinstance(m, ResultMessage):
last, session_id = m, m.session_id
print(f" run {attempt + 1}: subtype={m.subtype} stop_reason={m.stop_reason!r}"
+ (f" deferred={m.deferred_tool_use.name}({m.deferred_tool_use.input.get('command')!r})" if m.deferred_tool_use else ""))
break # stop at the FIRST result: a deferred run is over
if last is None or last.stop_reason != "tool_deferred":
return last
print(" ... the process could exit here; hours pass; a human approves in your UI ...")
APPROVALS.add(PENDING.pop(session_id)) # (3) record the approval
prompt = "" # (4) resume with no new prompt
return last
async def main():
result = await run_until_not_deferred("Deploy the app by running ./deploy.sh, then report what it printed.")
print(f"\nfinal: {result.subtype} -> {result.result!r}")
asyncio.run(main())
69 lines · Back to the folder