#!/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