#!/usr/bin/env bash # PreToolUse consultation gate (ADR-003). # # Matched tools (see .claude/settings.json): Write, Edit, MultiEdit, # NotebookEdit, Bash, mcp__filesystem__{write_file,edit_file,move_file}. # Denies writes to SAP artifacts unless a grounding consultation (one of the # SAP/UI5/context7 MCP servers, WebSearch, or WebFetch) was recorded in the # last 30 minutes by mark-consult.sh. # # Always exits 0. A deny is expressed ONLY via hookSpecificOutput JSON on # stdout (permissionDecision: "deny"); silence means "no opinion". # # FAIL-CLOSED RULES # * jq missing -> deny (cannot parse the tool input at all). # * marker unresolvable -> deny, but only once a gated path is involved. # * marker missing/stale -> deny for gated paths. # # GATED PATHS (after exemptions): # *.cds *.abap *.asddls *.acds *.bdef *.ddls *.view.xml *.fragment.xml # */srv/* */app/* basename manifest.json or annotation*.xml # EXEMPT: *.md */.claude/* */docs/* # NOT gated: */worker/* (TypeScript worker is governed by context7, not this gate). # # BASH HEURISTIC (best effort, documented limits): # A Bash command is gated only when it BOTH (a) contains a write indicator # (> or >> redirect, heredoc <<, sed -i/--in-place, tee, cp, mv, install, # rsync, rm, ln, touch, truncate, dd, patch, or python/node/perl/ruby with # -c/-e/-i) AND (b) mentions a gated path/extension as a whitespace token. # Redirects to /dev/null and fd duplications (2>&1) are ignored. # Limits: tokens are split on whitespace and shell punctuation, so paths # inside quotes with spaces, paths built from variables ($DIR/x), globs, # `cd app && ...` relative paths, and writes performed by scripts invoked by # name are NOT detected. sed expressions that happen to contain "/app/" or # "/srv/" can cause a false deny when combined with a write indicator. # Read-only commands (cat, grep, sed -n, head, ls, find) pass. # # ENV OVERRIDES # SAP_CONSULT_MARKER path to the marker file (tests only). # CLAUDE_PROJECT_DIR set by Claude Code; preferred over the $0-relative # fallback for locating .claude/.last-mcp-consult. deny() { # $1 must not contain double quotes or backslashes (kept JSON-safe by hand # so that a deny still works when jq itself is unavailable). printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$1" exit 0 } # --- Dependency check FIRST, using builtins only (fail closed). -------------- if ! command -v jq >/dev/null 2>&1; then deny "Ground-truth gate: required dependency jq is not on PATH, so the tool input cannot be inspected. Install jq (or fix PATH) and retry." fi INPUT="$(cat)" TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)" # is_gated PATH -> 0 if the path is a gated SAP artifact, 1 otherwise. is_gated() { local p="$1" norm base [ -z "$p" ] && return 1 norm="/${p#/}" # relative paths get a leading slash so */app/* matches base="${p##*/}" case "$norm" in *.md|*/.claude/*|*/docs/*) return 1 ;; esac case "$norm" in *.cds|*.abap|*.asddls|*.acds|*.bdef|*.ddls) return 0 ;; *.view.xml|*.fragment.xml) return 0 ;; */srv/*|*/app/*) return 0 ;; esac case "$base" in manifest.json|annotation*.xml) return 0 ;; esac return 1 } GATED=0 GATED_HINT="" if [ "$TOOL" = "Bash" ]; then CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)" [ -z "$CMD" ] && exit 0 # Drop redirects that never write a file: fd dups (2>&1, >&2) and /dev/null. SAN="$(printf '%s' "$CMD" \ | sed -E 's/[0-9]*>&[0-9]+//g; s/[&0-9]*>>?[[:space:]]*\/dev\/null//g')" WRITE_RE='(>|<<|(^|[[:space:];&|(])(tee|cp|mv|install|rsync|rm|ln|touch|truncate|dd|patch)([[:space:]]|$)|(^|[[:space:];&|(])sed([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[[:alnum:]]*i[[:alnum:]]*|--in-place)([[:space:]=]|$)|(^|[[:space:];&|(])(python[0-9.]*|node|perl|ruby)([[:space:]]+[^[:space:]]+)*[[:space:]]+-[[:alnum:]]*[cei]([[:space:]]|$))' if ! printf '%s' "$SAN" | grep -qE "$WRITE_RE"; then exit 0 fi # Tokenise on whitespace and shell punctuation; test every token as a path. TOKENS="$(printf '%s' "$CMD" | tr ';|&()<>"'"'"'`=,' ' ' | tr '\n' ' ')" for tok in $TOKENS; do if is_gated "$tok"; then GATED=1 GATED_HINT="$tok" break fi done else PATHS="$(printf '%s' "$INPUT" | jq -r ' .tool_input as $t | [ $t.file_path, $t.notebook_path, $t.path, $t.destination, $t.source ] | map(select(type == "string" and length > 0)) | .[]' 2>/dev/null)" [ -z "$PATHS" ] && exit 0 while IFS= read -r p; do if is_gated "$p"; then GATED=1 GATED_HINT="$p" break fi done </dev/null && pwd)" || REPO="" fi if [ -z "$REPO" ]; then deny "Ground-truth gate: cannot resolve the repository root (CLAUDE_PROJECT_DIR unset and script location unreadable), so the consultation marker cannot be checked. Failing closed." fi MARKER="$REPO/.claude/.last-mcp-consult" fi # --- Freshness check: marker younger than 30 minutes satisfies the gate. ----- if [ -f "$MARKER" ]; then LAST="$(cat "$MARKER" 2>/dev/null)" NOW="$(date +%s)" case "$LAST" in ''|*[!0-9]*) : ;; # not a number -> treat as stale *) if [ $((NOW - LAST)) -lt 1800 ]; then exit 0 fi ;; esac fi # Keep the hint JSON-safe (strip quotes/backslashes, truncate). SAFE_HINT="$(printf '%s' "$GATED_HINT" | tr -d '"\\' | cut -c1-160)" deny "Ground-truth gate: no grounding consultation recorded in the last 30 minutes, and this touches a SAP artifact ($SAFE_HINT). Consult the relevant server first (CDS/RAP/ABAP -> abap-docs or sap-docs; Fiori/annotations -> fiori-mcp-server; UI5 -> ui5-mcp-server; libraries -> context7). Only calls to those servers, WebSearch, or WebFetch refresh the gate; then retry."