mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
`<pipe JSON> | python3 - <<'PY'` makes both `python3 -` and the heredoc claim
stdin; the heredoc wins, so the piped JSON is silently discarded — each hook
read empty input and never triggered. Fixed to `python3 -c "$(cat <<'PY')"`
(cat consumes the heredoc, python3's stdin stays free for the pipe), matching
the already-correct fable-stop-gate-hook.sh.
Impact, all verified before/after:
- user-prompt-hook.sh: the prompt-injection guard ALLOWED injection strings
(exit 0); now correctly DENIES (exit 2). Security hole closed.
- post-tool-budget-hook.sh: every tool call hashed to {tool:unknown} — loop
detection was blind; now hashes the real tool + args.
- usage-report-hook.sh: transcript path resolved empty so its curl sync never
fired; now fires correctly.
2-line change per file; no logic/threshold/message/contract change. bash-guard
78/78 + fable-hooks 15/15 green; repo-wide grep confirms no remaining instances.
85 lines
3.8 KiB
Bash
85 lines
3.8 KiB
Bash
#!/usr/bin/env bash
|
|
# PostToolUse: per-session budget counter + loop detector.
|
|
#
|
|
# Runs after every tool call. Posts a (tool, args_hash) pair to the SDK
|
|
# server, which tracks cumulative counts and a rolling window of identical
|
|
# calls. Emits a short reminder line to stdout when thresholds are hit so
|
|
# Claude sees it in the next turn:
|
|
#
|
|
# [Budget] — soft warning (past warn threshold)
|
|
# [Loop] — same tool+args ≥ loop_threshold times in the window.
|
|
# When the SDK reports loop_action="halt" (foundation default
|
|
# BudgetPolicy.loop_action), the hook exits 1 to deny the
|
|
# wrapping tool call. Operators can soften with
|
|
# ROBOCO_AGENT_LOOP_ACTION=warn.
|
|
# [Halt] — hard cap breached; orchestrator kill-switch will terminate
|
|
# the container on its next sweep. Hook also fires the
|
|
# auto-escalate on the agent's behalf.
|
|
#
|
|
# Default: exit 0 (reminder). Exit 1 only on loop+halt.
|
|
|
|
set -u
|
|
|
|
SDK_URL="${ROBOCO_SDK_URL:-http://localhost:9000}"
|
|
input=$(cat 2>/dev/null || true)
|
|
[[ -z "$input" ]] && exit 0
|
|
|
|
# Strip MCP prefix, keep tool_input deterministic for hash.
|
|
read -r TOOL ARGS_HASH <<<"$(printf '%s' "$input" | python3 -c "$(cat <<'PY'
|
|
import json, sys, hashlib
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
tool = d.get("tool_name", "")
|
|
ti = d.get("tool_input") or {}
|
|
blob = json.dumps(ti, sort_keys=True, separators=(",", ":"), default=str)
|
|
h = hashlib.sha256(blob.encode("utf-8", errors="ignore")).hexdigest()[:16]
|
|
print(f"{tool} {h}")
|
|
except Exception:
|
|
print("unknown unknown")
|
|
PY
|
|
)")"
|
|
|
|
# Record the tool name on the SDK so the stop-hook can recognize a
|
|
# graceful terminal call (i_am_idle / i_am_done / pass / fail / etc.).
|
|
# Fire-and-forget — never block Claude on this.
|
|
curl -sf -m 2 -X POST "$SDK_URL/terminal/tool_recorded" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"tool\":\"$TOOL\"}" >/dev/null 2>&1 || true
|
|
|
|
# Ask the SDK to record + return status. 2s timeout — we never block Claude.
|
|
resp=$(curl -sf -m 2 -X POST "$SDK_URL/budget/tool_called" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"tool\":\"$TOOL\",\"args_hash\":\"$ARGS_HASH\"}" 2>/dev/null)
|
|
|
|
[[ -z "$resp" ]] && exit 0
|
|
|
|
total=$(echo "$resp" | jq -r '.total // 0')
|
|
warn=$(echo "$resp" | jq -r '.warn // false')
|
|
halt=$(echo "$resp" | jq -r '.halt // false')
|
|
loop=$(echo "$resp" | jq -r '.loop // false')
|
|
loop_action=$(echo "$resp" | jq -r '.loop_action // "warn"')
|
|
halt_threshold=$(echo "$resp" | jq -r '.halt_threshold // 150')
|
|
|
|
if [[ "$halt" == "true" ]]; then
|
|
echo "[Halt] Budget exceeded: ${total}/${halt_threshold} tool calls. Auto-escalating; stop now."
|
|
# Fire-and-forget the substitute so the task gets released even if the
|
|
# agent ignores the message. Orchestrator sweep will terminate the
|
|
# container within agent_budget_sweep_interval_seconds anyway.
|
|
curl -sf -m 2 -X POST "$SDK_URL/terminal/force_substitute" >/dev/null 2>&1 || true
|
|
elif [[ "$loop" == "true" ]]; then
|
|
if [[ "$loop_action" == "halt" ]]; then
|
|
# Foundation BudgetPolicy.loop_action="halt": deny the wrapping tool
|
|
# call so the agent cannot keep retrying the same (tool,args) pair.
|
|
# Only fires when the SDK explicitly reports loop_action=="halt";
|
|
# if the field is missing (older SDK / partial deploy), falls
|
|
# through to the legacy warn-only branch below.
|
|
echo "[Loop] Same tool+args repeated in window — halting (BudgetPolicy.loop_action=halt). Release the task with unclaim() or stop with i_am_idle()." >&2
|
|
exit 1
|
|
fi
|
|
echo "[Loop] Same tool+args repeated in window. Stop looping — release the task with unclaim() or stop with i_am_idle()."
|
|
elif [[ "$warn" == "true" ]]; then
|
|
echo "[Budget] ${total}/${halt_threshold} tool calls used. Plan your remaining work carefully."
|
|
fi
|
|
|
|
exit 0
|