#!/usr/bin/env bash # PostToolUse hook: registra cada invocacion del agente en # projects/fn_monitoring/apps/call_monitor/operations.db (issue 0085b). # # Identifica tool, extrae function_id cuando es posible, clasifica el patron # (mcp_*, fn_cli_run, heredoc_py, sqlite_direct, edit_registry, ...) y # detecta antipatrones para registrar violations. # # NUNCA bloquea la herramienta. Falla silenciosamente si la BD no esta lista. # Solo guarda args_hash, jamas valores concretos. set -euo pipefail # ---- Resolve registry root (walks up from cwd looking for registry.db) ---- resolve_root() { local d="${PWD}" while [ "$d" != "/" ]; do if [ -f "$d/registry.db" ]; then printf '%s' "$d" return 0 fi d=$(dirname "$d") done return 1 } ROOT=$(resolve_root) || exit 0 DB="$ROOT/projects/fn_monitoring/apps/call_monitor/operations.db" # Si la BD aun no existe, el hook no hace nada (esperando init). [ -f "$DB" ] || exit 0 # ---- Read stdin JSON ---- INPUT=$(cat) if [ -z "$INPUT" ]; then exit 0; fi # Required jq presence command -v jq >/dev/null 2>&1 || exit 0 command -v sqlite3 >/dev/null 2>&1 || exit 0 TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""') SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""') TS=$(date -u +%s) # Tool response success/error SUCCESS=1 ERROR_CLASS="" ERROR_SNIPPET="" RESP_IS_ERROR=$(printf '%s' "$INPUT" | jq -r 'if (.tool_response | type) == "object" then (.tool_response.is_error // false) else false end') if [ "$RESP_IS_ERROR" = "true" ]; then SUCCESS=0 ERROR_SNIPPET=$(printf '%s' "$INPUT" | jq -r 'if (.tool_response | type) == "object" then (.tool_response.error // .tool_response.content // "") else "" end' | head -c 240 | tr '\n' ' ') fi # args_hash: sha256 truncado del tool_input (sin valores) ARGS_HASH=$(printf '%s' "$INPUT" | jq -c '.tool_input // {}' | sha256sum | cut -c1-16) # Helpers SQL sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } insert_call() { local fn_id="$1" tool_used="$2" duration_ms="${3:-0}" local fn_esc tu_esc ec_esc es_esc sid_esc ah_esc fn_esc=$(sql_escape "$fn_id") tu_esc=$(sql_escape "$tool_used") ec_esc=$(sql_escape "$ERROR_CLASS") es_esc=$(sql_escape "$ERROR_SNIPPET") sid_esc=$(sql_escape "$SESSION_ID") ah_esc=$(sql_escape "$ARGS_HASH") sqlite3 "$DB" "INSERT INTO calls (session_id, function_id, tool_used, args_hash, duration_ms, success, error_class, error_snippet, ts) VALUES ('$sid_esc','$fn_esc','$tu_esc','$ah_esc',$duration_ms,$SUCCESS,'$ec_esc','$es_esc',$TS);" 2>/dev/null || true } insert_code_write() { local fn_id="$1" file_path="$2" added="${3:-0}" removed="${4:-0}" local fn_esc fp_esc sid_esc fn_esc=$(sql_escape "$fn_id") fp_esc=$(sql_escape "$file_path") sid_esc=$(sql_escape "$SESSION_ID") sqlite3 "$DB" "INSERT INTO code_writes (session_id, function_id, file_path, lines_added, lines_removed, ts) VALUES ('$sid_esc','$fn_esc','$fp_esc',$added,$removed,$TS);" 2>/dev/null || true } # Snapshot a function version row when an edit lands on a registry file. # Uses sha256 of file bytes as content_hash (separate namespace from index source). insert_edit_version() { local fn_id="$1" abs_path="$2" [ -f "$abs_path" ] || return 0 command -v sha256sum >/dev/null 2>&1 || return 0 local hash hash=$(sha256sum "$abs_path" 2>/dev/null | awk '{print $1}') [ -z "$hash" ] && return 0 local fn_esc h_esc fn_esc=$(sql_escape "$fn_id") h_esc=$(sql_escape "$hash") sqlite3 "$DB" "INSERT OR IGNORE INTO function_versions (function_id, content_hash, version, snapped_at, source, lines_added, lines_removed) VALUES ('$fn_esc','$h_esc','',$TS,'edit_hook',0,0);" 2>/dev/null || true } insert_violation() { local rule_id="$1" fn_id="$2" snippet="$3" severity="${4:-warning}" local r_esc fn_esc sn_esc sev_esc sid_esc r_esc=$(sql_escape "$rule_id") fn_esc=$(sql_escape "$fn_id") sn_esc=$(sql_escape "$(printf '%s' "$snippet" | head -c 240 | tr '\n' ' ')") sev_esc=$(sql_escape "$severity") sid_esc=$(sql_escape "$SESSION_ID") sqlite3 "$DB" "INSERT INTO violations (session_id, rule_id, function_id, command_snippet, severity, ts) VALUES ('$sid_esc','$r_esc','$fn_esc','$sn_esc','$sev_esc',$TS);" 2>/dev/null || true } # ---- Derive function_id from registry file path ---- # Matches paths under functions//., python/functions//.py, # bash/functions//.sh, frontend/functions//.ts(x) derive_fn_id_from_path() { local p="$1" [ -z "$p" ] && return 1 case "$p" in functions/*/*.go|*/functions/*/*.go) local dom name dom=$(printf '%s' "$p" | sed -E 's|.*functions/([^/]+)/.*|\1|') name=$(printf '%s' "$p" | sed -E 's|.*functions/[^/]+/([^/.]+)\..*|\1|') [ -n "$dom" ] && [ -n "$name" ] && printf '%s_go_%s' "$name" "$dom" && return 0 ;; python/functions/*/*.py) local dom name dom=$(printf '%s' "$p" | sed -E 's|python/functions/([^/]+)/.*|\1|') name=$(printf '%s' "$p" | sed -E 's|python/functions/[^/]+/([^/.]+)\..*|\1|') [ -n "$dom" ] && [ -n "$name" ] && printf '%s_py_%s' "$name" "$dom" && return 0 ;; bash/functions/*/*.sh) local dom name dom=$(printf '%s' "$p" | sed -E 's|bash/functions/([^/]+)/.*|\1|') name=$(printf '%s' "$p" | sed -E 's|bash/functions/[^/]+/([^/.]+)\..*|\1|') [ -n "$dom" ] && [ -n "$name" ] && printf '%s_bash_%s' "$name" "$dom" && return 0 ;; frontend/functions/*/*.ts|frontend/functions/*/*.tsx) local dom name dom=$(printf '%s' "$p" | sed -E 's|frontend/functions/([^/]+)/.*|\1|') name=$(printf '%s' "$p" | sed -E 's|frontend/functions/[^/]+/([^/.]+)\..*|\1|') [ -n "$dom" ] && [ -n "$name" ] && printf '%s_ts_%s' "$name" "$dom" && return 0 ;; esac return 1 } # ---- Dispatch by tool ---- case "$TOOL_NAME" in mcp__registry__fn_search) insert_call "" "mcp_fn_search" ;; mcp__registry__fn_show) ID=$(printf '%s' "$INPUT" | jq -r '.tool_input.id // ""') insert_call "$ID" "mcp_fn_show" ;; mcp__registry__fn_code) ID=$(printf '%s' "$INPUT" | jq -r '.tool_input.id // ""') insert_call "$ID" "mcp_fn_code" ;; mcp__registry__fn_uses) ID=$(printf '%s' "$INPUT" | jq -r '.tool_input.id // ""') insert_call "$ID" "mcp_fn_uses" ;; mcp__registry__fn_run) ID=$(printf '%s' "$INPUT" | jq -r '.tool_input.id // ""') insert_call "$ID" "mcp_fn_run" ;; mcp__registry__fn_list_domains) insert_call "" "mcp_fn_list_domains" ;; mcp__registry__fn_proposal) insert_call "" "mcp_fn_proposal" ;; mcp__registry__fn_doctor) insert_call "" "mcp_fn_doctor" ;; mcp__registry__fn_create_function) insert_call "" "mcp_fn_create_function" ;; Edit|Write|MultiEdit) FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""') ABS_PATH="$FILE_PATH" # Make path relative to root if absolute and inside root case "$FILE_PATH" in "$ROOT"/*) FILE_PATH="${FILE_PATH#$ROOT/}" ;; /*) ABS_PATH="$FILE_PATH" ;; *) ABS_PATH="$ROOT/$FILE_PATH" ;; esac FN_ID=$(derive_fn_id_from_path "$FILE_PATH" || true) if [ -n "$FN_ID" ]; then insert_code_write "$FN_ID" "$FILE_PATH" 0 0 insert_call "$FN_ID" "edit_registry" insert_edit_version "$FN_ID" "$ABS_PATH" fi ;; Bash) CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""') CMD_HEAD=$(printf '%s' "$CMD" | head -c 200 | tr '\n' ' ') # Classify TOOL_USED="bash_other" FN_ID="" if printf '%s' "$CMD" | grep -qE '(^|[[:space:]])\./fn[[:space:]]+run[[:space:]]+'; then TOOL_USED="fn_cli_run" FN_ID=$(printf '%s' "$CMD" | sed -nE 's/.*\.\/fn[[:space:]]+run[[:space:]]+([A-Za-z0-9_]+).*/\1/p' | head -n1) elif printf '%s' "$CMD" | grep -qE 'python/\.venv/bin/python3[[:space:]]+-[[:space:]]+<<'; then TOOL_USED="heredoc_py" elif printf '%s' "$CMD" | grep -qE 'sqlite3[[:space:]][^|]*\bregistry\.db\b'; then TOOL_USED="sqlite_direct" fi insert_call "$FN_ID" "$TOOL_USED" # ---- Violation rules ---- # 1. sqlite3 directo SELECT sobre registry.db (excepto schema/pragma/count/join) if [ "$TOOL_USED" = "sqlite_direct" ]; then if ! printf '%s' "$CMD" | grep -qiE '(\.schema|\.tables|PRAGMA[[:space:]]+(table_info|index_list)|COUNT\(|GROUP[[:space:]]+BY|JOIN[[:space:]])'; then insert_violation "sqlite3_registry_select" "" "$CMD_HEAD" "warning" fi fi # 2. python -c "import X; dir(X)" if printf '%s' "$CMD" | grep -qE 'python[3]?[[:space:]]+-c[[:space:]]+["'\''].*import.*(dir|help)\('; then insert_violation "python_dir_inspect" "" "$CMD_HEAD" "info" fi # 3. from import * (en heredoc python) if [ "$TOOL_USED" = "heredoc_py" ]; then if printf '%s' "$CMD" | grep -qE 'from[[:space:]]+[A-Za-z0-9_.]+[[:space:]]+import[[:space:]]+\*'; then insert_violation "import_star_in_heredoc" "" "$CMD_HEAD" "warning" fi if printf '%s' "$CMD" | grep -qE 'client\._http\.request\('; then insert_violation "client_http_request_direct" "" "$CMD_HEAD" "warning" fi fi ;; esac exit 0