Session 4 step 17 — 5 autonomy levels (0-4), PreToolUse approval-gate hook polls approval-responses.jsonl with 60s timeout, blocks on no-response. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
162 lines
4.7 KiB
Bash
162 lines
4.7 KiB
Bash
#!/bin/bash
|
|
# PreToolUse hook: Implement approval gates based on GOVERNANCE.md.
|
|
# Bash 3.2 compatible. Uses python3 for JSON/MD parsing.
|
|
#
|
|
# Follows Paperclip's approval mechanism:
|
|
# 1. Read GOVERNANCE.md for current autonomy level and gate definitions
|
|
# 2. Auto-approve or require human approval based on level
|
|
# 3. Write pending approval requests; check for responses
|
|
# 4. Timeout with no response → block
|
|
#
|
|
# Placeholders:
|
|
# {{WORKING_DIR}} - absolute path to project directory
|
|
|
|
WORKING_DIR="{{WORKING_DIR}}"
|
|
GOVERNANCE_DIR="$WORKING_DIR/governance"
|
|
GOVERNANCE_FILE="$WORKING_DIR/GOVERNANCE.md"
|
|
PENDING_FILE="$GOVERNANCE_DIR/pending-approvals.jsonl"
|
|
RESPONSES_FILE="$GOVERNANCE_DIR/approval-responses.jsonl"
|
|
AUDIT_LOG="$GOVERNANCE_DIR/audit.log"
|
|
APPROVALS_LOG="$GOVERNANCE_DIR/approvals.log"
|
|
APPROVAL_TIMEOUT=60
|
|
|
|
mkdir -p "$GOVERNANCE_DIR"
|
|
|
|
# Read hook input
|
|
INPUT=$(cat)
|
|
|
|
# Auto-approve tools based on autonomy level
|
|
DECISION=$(GOVERNANCE_FILE="$GOVERNANCE_FILE" python3 -c "
|
|
import re, json, sys, os
|
|
|
|
governance_file = os.environ.get('GOVERNANCE_FILE', '')
|
|
|
|
try:
|
|
data = json.loads('''$INPUT''')
|
|
except:
|
|
print('approve')
|
|
sys.exit(0)
|
|
|
|
tool_name = data.get('tool_name', '')
|
|
|
|
# Read governance policy
|
|
if not os.path.exists(governance_file):
|
|
print('approve')
|
|
sys.exit(0)
|
|
|
|
content = open(governance_file).read()
|
|
level_m = re.search(r'Current level:\s*(\d+)', content)
|
|
level = int(level_m.group(1)) if level_m else 0
|
|
|
|
# Safe read-only tools (always approved at level 1+)
|
|
read_tools = ['Read', 'Glob', 'Grep', 'LS']
|
|
# File operation tools (approved at level 2+)
|
|
file_tools = ['Write', 'Edit', 'MultiEdit']
|
|
# Non-destructive bash (approved at level 3+)
|
|
# Level 4: everything auto-approved
|
|
|
|
if level >= 4:
|
|
print('approve')
|
|
elif level >= 3 and tool_name not in ['Bash']:
|
|
print('approve')
|
|
elif level >= 2 and tool_name in read_tools + file_tools:
|
|
print('approve')
|
|
elif level >= 1 and tool_name in read_tools:
|
|
print('approve')
|
|
else:
|
|
print('gate')
|
|
" 2>/dev/null)
|
|
|
|
# Log every tool call to audit log
|
|
python3 -c "
|
|
import json, time, os
|
|
try:
|
|
data = json.loads('''$INPUT''')
|
|
tool_name = data.get('tool_name', 'unknown')
|
|
entry = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) + ' TOOL ' + tool_name + ' decision=$DECISION'
|
|
with open('$AUDIT_LOG', 'a') as f:
|
|
f.write(entry + '\n')
|
|
except:
|
|
pass
|
|
" 2>/dev/null
|
|
|
|
if [ "$DECISION" = "approve" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Gate: write pending approval request
|
|
REQUEST_ID=$(python3 -c "import time; print(str(int(time.time())))" 2>/dev/null)
|
|
python3 -c "
|
|
import json, time, os
|
|
try:
|
|
data = json.loads('''$INPUT''')
|
|
tool_name = data.get('tool_name', 'unknown')
|
|
tool_input = data.get('tool_input', {})
|
|
req = {
|
|
'id': '$REQUEST_ID',
|
|
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
|
'tool_name': tool_name,
|
|
'tool_input_summary': str(tool_input)[:200],
|
|
'status': 'pending'
|
|
}
|
|
with open('$PENDING_FILE', 'a') as f:
|
|
f.write(json.dumps(req) + '\n')
|
|
print('Approval required for: ' + tool_name)
|
|
print('Request ID: $REQUEST_ID')
|
|
print('Add response to: $RESPONSES_FILE')
|
|
print('Format: {\"id\": \"$REQUEST_ID\", \"decision\": \"approve\"}')
|
|
except:
|
|
pass
|
|
" >&2
|
|
|
|
# Poll for response
|
|
SECONDS_WAITED=0
|
|
while [ "$SECONDS_WAITED" -lt "$APPROVAL_TIMEOUT" ]; do
|
|
if [ -f "$RESPONSES_FILE" ]; then
|
|
RESPONSE=$(python3 -c "
|
|
import json, os
|
|
req_id = '$REQUEST_ID'
|
|
responses_file = '$RESPONSES_FILE'
|
|
try:
|
|
with open(responses_file) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line:
|
|
try:
|
|
r = json.loads(line)
|
|
if r.get('id') == req_id:
|
|
print(r.get('decision', 'deny'))
|
|
exit(0)
|
|
except:
|
|
pass
|
|
except:
|
|
pass
|
|
print('pending')
|
|
" 2>/dev/null)
|
|
if [ "$RESPONSE" = "approve" ]; then
|
|
python3 -c "
|
|
import json, time
|
|
entry = {'id': '$REQUEST_ID', 'timestamp': '$(date -u +%Y-%m-%dT%H:%M:%SZ)', 'decision': 'approved'}
|
|
with open('$APPROVALS_LOG', 'a') as f:
|
|
f.write(json.dumps(entry) + '\n')
|
|
" 2>/dev/null
|
|
exit 0
|
|
elif [ "$RESPONSE" = "deny" ]; then
|
|
python3 -c "
|
|
import json, time
|
|
entry = {'id': '$REQUEST_ID', 'timestamp': '$(date -u +%Y-%m-%dT%H:%M:%SZ)', 'decision': 'denied'}
|
|
with open('$APPROVALS_LOG', 'a') as f:
|
|
f.write(json.dumps(entry) + '\n')
|
|
" 2>/dev/null
|
|
echo '{"decision": "block", "reason": "Approval denied by operator."}'
|
|
exit 2
|
|
fi
|
|
fi
|
|
sleep 5
|
|
SECONDS_WAITED=$(( SECONDS_WAITED + 5 ))
|
|
done
|
|
|
|
# Timeout — block
|
|
echo "Approval timeout after ${APPROVAL_TIMEOUT}s — blocking tool call." >&2
|
|
echo '{"decision": "block", "reason": "Approval timeout. Check governance/pending-approvals.jsonl."}'
|
|
exit 2
|