upload current sources

This commit is contained in:
2026-09-16 01:47:28 -07:00
commit f8b45ea4a5
109 changed files with 26012 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# PostToolUse hook for the grounding servers (sap-docs, abap-docs,
# sap-released-objects, fiori/ui5 servers, context7, capire) and
# WebSearch/WebFetch: records the time of the last successful consultation.
# The matcher in .claude/settings.json decides WHICH tools count; this script
# only stamps the marker. Drains stdin so the pipe never breaks. Always exits 0.
#
# Marker location, in priority order:
# 1. $SAP_CONSULT_MARKER (test/override hook; never set in normal use)
# 2. $CLAUDE_PROJECT_DIR/.claude/.last-mcp-consult (set by Claude Code)
# 3. <this script>/../../.claude/.last-mcp-consult (fallback)
cat > /dev/null
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
[ -z "$REPO" ] && exit 0
MARKER="$REPO/.claude/.last-mcp-consult"
fi
mkdir -p "$(dirname "$MARKER")" 2>/dev/null
date +%s > "$MARKER" 2>/dev/null
exit 0
+153
View File
@@ -0,0 +1,153 @@
#!/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."
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Prints ground-truth grounding context for SessionStart ("session") or
# UserPromptSubmit ("prompt"). Stdout becomes model context. Always exits 0.
case "${1:-}" in
session)
cat <<'EOF'
[SAP GROUND-TRUTH CHARTER — this repo enforces MCP-first grounding]
Forget what you think you know about SAP, CAP, RAP, ABAP Cloud, CDS, Fiori, and UI5
APIs — training knowledge is stale by default and is NOT a citable source here.
Authoritative sources, in order: this repo's MCP servers (abap-docs, sap-docs,
sap-released-objects, fiori-mcp-server, ui5-mcp-server), context7 for non-SAP
libraries/SDKs, live system checks. If MCP tools show as deferred, load them via
ToolSearch as your FIRST action — do not defer the load until "needed".
Every SAP behavioral claim you record in PRD/ADR/KNOWLEDGE must carry a citation:
[source: <server> <doc-id-or-URL>, verified YYYY-MM-DD]. Uncited = hypothesis.
A consultation gate is active: writes to SAP artifacts (Write/Edit/Bash/filesystem MCP) are DENIED unless a
grounding-server (SAP/UI5/context7 MCP), WebSearch, or WebFetch consultation happened in the last 30 minutes.
EOF
;;
prompt)
cat <<'EOF'
[SAP-REFLEX] Do not classify whether this prompt "needs" an MCP consult — that
classification is the step that fails. CDS/RAP/ABAP/Fiori/UI5/annotation/OData
work ⇒ consult abap-docs/sap-docs/fiori-mcp-server/ui5-mcp-server BEFORE writing
anything. Library/SDK questions ⇒ context7. Record SAP claims only with
[source: <server> <id/URL>, verified YYYY-MM-DD].
EOF
;;
esac
exit 0
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Test harness for the ADR-003 consultation gate hooks.
# Pipes simulated Claude Code hook JSON into sap-consult-gate.sh and
# mark-consult.sh with a temp marker (SAP_CONSULT_MARKER) so the real
# .claude/.last-mcp-consult is never touched.
#
# Usage: bash .claude/hooks/tests/run.sh (exit 0 = all pass)
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
HOOKS="$(dirname "$HERE")"
REPO="$(cd "$HOOKS/../.." && pwd)"
GATE="$HOOKS/sap-consult-gate.sh"
MARK="$HOOKS/mark-consult.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
STALE="$TMP/stale-marker"
FRESH="$TMP/fresh-marker"
echo $(( $(date +%s) - 7200 )) > "$STALE"
date +%s > "$FRESH"
PASS=0
FAIL=0
# gate MARKER JSON -> prints "deny" or "allow"; also asserts exit 0 + valid JSON.
gate() {
local marker="$1" json="$2" out rc
out="$(printf '%s' "$json" | SAP_CONSULT_MARKER="$marker" CLAUDE_PROJECT_DIR="$REPO" bash "$GATE")"
rc=$?
if [ "$rc" -ne 0 ]; then echo "rc=$rc"; return; fi
if [ -n "$out" ] && ! printf '%s' "$out" | jq -e . >/dev/null 2>&1; then echo "badjson"; return; fi
if printf '%s' "$out" | jq -e '.hookSpecificOutput.permissionDecision == "deny"' >/dev/null 2>&1; then
echo deny
else
echo allow
fi
}
expect() {
local name="$1" want="$2" got="$3"
if [ "$want" = "$got" ]; then
PASS=$((PASS + 1)); echo "PASS $name [$got]"
else
FAIL=$((FAIL + 1)); echo "FAIL $name [want $want, got $got]"
fi
}
j_edit() { printf '{"tool_name":"Edit","tool_input":{"file_path":"%s","old_string":"a","new_string":"b"}}' "$1"; }
j_bash() { printf '{"tool_name":"Bash","tool_input":{"command":%s}}' "$(printf '%s' "$1" | jq -Rs .)"; }
j_fswr() { printf '{"tool_name":"mcp__filesystem__write_file","tool_input":{"path":"%s","content":"x"}}' "$1"; }
j_fsmv() { printf '{"tool_name":"mcp__filesystem__move_file","tool_input":{"source":"%s","destination":"%s"}}' "$1" "$2"; }
j_nb() { printf '{"tool_name":"NotebookEdit","tool_input":{"notebook_path":"%s","new_source":"x"}}' "$1"; }
MANIFEST="$REPO/app/webapp/manifest.json"
ANNOT="$REPO/app/webapp/annotations/annotation.xml"
echo "== sap-consult-gate.sh =="
# --- Cases required by the brief ---------------------------------------------
expect "Edit manifest.json, stale marker => deny" deny "$(gate "$STALE" "$(j_edit "$MANIFEST")")"
expect "Edit manifest.json, fresh marker => allow" allow "$(gate "$FRESH" "$(j_edit "$MANIFEST")")"
expect "Bash sed -i manifest.json, stale => deny" deny "$(gate "$STALE" "$(j_bash "sed -i 's/a/b/' app/webapp/manifest.json")")"
expect "Bash cat manifest.json, stale => allow" allow "$(gate "$STALE" "$(j_bash "cat app/webapp/manifest.json")")"
expect "Edit worker/src/x.ts, stale => allow" allow "$(gate "$STALE" "$(j_edit "$REPO/worker/src/x.ts")")"
expect "Edit PRD.md, stale => allow" allow "$(gate "$STALE" "$(j_edit "$REPO/PRD.md")")"
expect "fs write_file annotation.xml, stale => deny" deny "$(gate "$STALE" "$(j_fswr "$ANNOT")")"
# missing jq: empty PATH, fresh marker, gated file -> must still deny, naming jq.
out="$(printf '%s' "$(j_edit "$MANIFEST")" | env -i PATH=/nonexistent SAP_CONSULT_MARKER="$FRESH" /bin/bash "$GATE")"; rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | jq -e '.hookSpecificOutput.permissionDecision == "deny" and (.hookSpecificOutput.permissionDecisionReason | test("jq"))' >/dev/null 2>&1; then
got=deny; else got="allow(rc=$rc)"; fi
expect "missing jq (PATH stripped) => deny naming jq" deny "$got"
# --- Additional coverage --------------------------------------------------------
expect "Bash sed -i manifest.json, fresh => allow" allow "$(gate "$FRESH" "$(j_bash "sed -i 's/a/b/' app/webapp/manifest.json")")"
expect "Bash sed -n (read-only) on manifest, stale => allow" allow "$(gate "$STALE" "$(j_bash "sed -n '1,20p' app/webapp/manifest.json")")"
expect "Bash grep on app/ path, stale => allow" allow "$(gate "$STALE" "$(j_bash "grep -rn foo app/webapp/")")"
expect "Bash cat with 2>/dev/null, stale => allow" allow "$(gate "$STALE" "$(j_bash "cat app/webapp/manifest.json 2>/dev/null")")"
expect "Bash cat 2>&1 | head, stale => allow" allow "$(gate "$STALE" "$(j_bash "cat app/webapp/manifest.json 2>&1 | head -5")")"
expect "Bash heredoc > .cds, stale => deny" deny "$(gate "$STALE" "$(j_bash "cat > srv/schema.cds <<'X'
entity A {}
X")")"
expect "Bash echo >> manifest.json, stale => deny" deny "$(gate "$STALE" "$(j_bash "echo '{}' >> app/webapp/manifest.json")")"
expect "Bash tee annotation.xml, stale => deny" deny "$(gate "$STALE" "$(j_bash "printf x | tee app/webapp/annotations/annotation.xml")")"
expect "Bash cp into app/, stale => deny" deny "$(gate "$STALE" "$(j_bash "cp /tmp/x.json app/webapp/manifest.json")")"
expect "Bash mv within app/, stale => deny" deny "$(gate "$STALE" "$(j_bash "mv app/webapp/a.xml app/webapp/b.xml")")"
expect "Bash python -c writing .abap, stale => deny" deny "$(gate "$STALE" "$(j_bash "python3 -c 'open(\"src/zcl_x.clas.abap\",\"w\").write(\"x\")'")")"
expect "Bash node -e writing manifest, stale => deny" deny "$(gate "$STALE" "$(j_bash "node -e 'require(\"fs\").writeFileSync(\"app/webapp/manifest.json\",\"{}\")'")")"
expect "Bash echo > worker file, stale => allow" allow "$(gate "$STALE" "$(j_bash "echo x > worker/src/x.ts")")"
expect "Bash echo > docs file, stale => allow" allow "$(gate "$STALE" "$(j_bash "echo x > docs/adr/ADR-009.md")")"
expect "Bash npm test (no write), stale => allow" allow "$(gate "$STALE" "$(j_bash "cd worker && npm test")")"
expect "Edit .bdef, stale => deny" deny "$(gate "$STALE" "$(j_edit "$REPO/src/zi_x.bdef")")"
expect "Edit fragment.xml, stale => deny" deny "$(gate "$STALE" "$(j_edit "$REPO/app/webapp/ext/X.fragment.xml")")"
expect "Edit app/README.md (exempt ext), stale => allow" allow "$(gate "$STALE" "$(j_edit "$REPO/app/README.md")")"
expect "Edit .claude/hooks file, stale => allow" allow "$(gate "$STALE" "$(j_edit "$REPO/.claude/hooks/x.sh")")"
expect "NotebookEdit under app/, stale => deny" deny "$(gate "$STALE" "$(j_nb "$REPO/app/x.ipynb")")"
expect "fs move_file src gated, stale => deny" deny "$(gate "$STALE" "$(j_fsmv "$MANIFEST" "/tmp/m.json")")"
expect "fs move_file dest gated, stale => deny" deny "$(gate "$STALE" "$(j_fsmv "/tmp/m.json" "$MANIFEST")")"
expect "Edit with no path field => allow" allow "$(gate "$STALE" '{"tool_name":"Edit","tool_input":{}}')"
expect "Corrupt marker content => deny" deny "$(printf 'garbage' > "$TMP/bad"; gate "$TMP/bad" "$(j_edit "$MANIFEST")")"
expect "Missing marker file => deny" deny "$(gate "$TMP/does-not-exist" "$(j_edit "$MANIFEST")")"
echo "== mark-consult.sh =="
M="$TMP/marked"
printf '{"tool_name":"mcp__sap-docs__search","tool_response":{}}' | SAP_CONSULT_MARKER="$M" CLAUDE_PROJECT_DIR="$REPO" bash "$MARK"; rc=$?
expect "mark-consult exits 0" 0 "$rc"
now=$(date +%s); v="$(cat "$M" 2>/dev/null || echo none)"
case "$v" in *[!0-9]*|'') got=bad ;; *) [ $((now - v)) -le 5 ] && got=fresh || got=old ;; esac
expect "mark-consult writes epoch to override marker" fresh "$got"
expect "gate allows after mark-consult stamped marker" allow "$(gate "$M" "$(j_edit "$MANIFEST")")"
[ -f "$REPO/.claude/.last-mcp-consult" ] && before="$(cat "$REPO/.claude/.last-mcp-consult")" || before="absent"
[ -f "$REPO/.claude/.last-mcp-consult" ] && after="$(cat "$REPO/.claude/.last-mcp-consult")" || after="absent"
expect "real marker untouched by harness" "$before" "$after"
echo
echo "passed=$PASS failed=$FAIL"
[ "$FAIL" -eq 0 ]