154 lines
6.1 KiB
Bash
Executable File
154 lines
6.1 KiB
Bash
Executable File
#!/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 <<EOP
|
|
$PATHS
|
|
EOP
|
|
fi
|
|
|
|
[ "$GATED" -eq 0 ] && exit 0
|
|
|
|
# --- Resolve marker (fail closed if impossible). ------------------------------
|
|
if [ -n "${SAP_CONSULT_MARKER:-}" ]; then
|
|
MARKER="$SAP_CONSULT_MARKER"
|
|
else
|
|
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then
|
|
REPO="$CLAUDE_PROJECT_DIR"
|
|
else
|
|
REPO="$(cd "$(dirname "$0")/../.." 2>/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."
|