#!/usr/bin/env bash set -euo pipefail # CASAN Level 5 tool registry gate. # Usage: # tool-registry-gate.sh TOOL_ID="${1:-}" if [[ -z "$TOOL_ID" ]]; then echo "Usage: tool-registry-gate.sh " >&2 exit 64 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" REGISTRY="$PROJECT_ROOT/.specify/level5/tool-registry.yaml" LOG_DIR="$PROJECT_ROOT/.specify/logs/level5" AUDIT_DIR="$PROJECT_ROOT/.specify/logs/audit" mkdir -p "$LOG_DIR" "$AUDIT_DIR" TRACE_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tool-%s-%s' "$(date +%s)" "$$")" # shellcheck source=tool-audit-lib.sh source "$SCRIPT_DIR/tool-audit-lib.sh" AUDIT_TMP="$(mktemp)" trap 'rm -f "$AUDIT_TMP"' EXIT DECISION_LINE="$(python - "$REGISTRY" "$TOOL_ID" "${CASAN_IDEMPOTENCY_KEY:-}" "$LOG_DIR/tool-registry.jsonl" "$TRACE_ID" "${CASAN_AGENT:-}" "$AUDIT_TMP" "${CASAN_RUN_ID:-adhoc-$$}" <<'PY' import json import os import re import sys from datetime import datetime, timezone registry_path, tool_id, idempotency_key, log_path, trace_id, agent, audit_tmp, run_id = sys.argv[1:] text = open(registry_path, encoding="utf-8").read() blocks = re.split(r"\n\s*-\s+id:\s+", text) tools = {} for block in blocks[1:]: lines = block.splitlines() current_id = lines[0].strip() attrs = {"id": current_id, "has_rollback": "strategy:" in block} for line in lines[1:]: if ":" in line and not line.startswith(" "): key, value = line.split(":", 1) attrs[key.strip()] = value.strip().strip('"') tools[current_id] = attrs tool = tools.get(tool_id) decision, reason = "approved", "registered" if not tool: decision, reason = "denied", "unknown_tool" side_effect = idem_required = False owner = risk = "" allowed = "" else: side_effect = tool.get("side_effect", "false") == "true" idem_required = tool.get("idempotency_required", "false") == "true" owner = tool.get("owner", "") risk = tool.get("risk_level", "") allowed = tool.get("allowed_agents", "") allowed_list = [a.strip() for a in allowed.split(",") if a.strip()] # 1. Per-agent least-privilege: restricted tools require an authorized caller. if allowed_list: if not agent: decision, reason = "denied", "missing_agent_identity" elif agent not in allowed_list: decision, reason = "denied", "unauthorized_agent" # 2. Side-effecting + idempotency-required tools must carry an idempotency key. if decision == "approved" and side_effect and idem_required and not idempotency_key: decision, reason = "denied", "missing_idempotency_key" # 3. Every side-effecting tool must declare a rollback strategy. if decision == "approved" and side_effect and not tool.get("has_rollback"): decision, reason = "denied", "missing_rollback_strategy" # 4. Runtime rate limit: count prior APPROVED calls for this tool in this run. if decision == "approved" and tool.get("rate_limit_per_run", "").isdigit(): limit = int(tool["rate_limit_per_run"]) prior = 0 if os.path.exists(log_path): for line in open(log_path, encoding="utf-8"): try: r = json.loads(line) except ValueError: continue if (r.get("tool_id") == tool_id and r.get("run_id") == run_id and r.get("decision") == "approved"): prior += 1 if prior >= limit: decision, reason = "denied", f"rate_limit_exceeded(limit={limit})" ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") record = { "timestamp": ts, "trace_id": trace_id, "harness": "L5-tool-registry", "tool_id": tool_id, "agent": agent, "run_id": run_id, "owner": owner, "risk_level": risk, "side_effect": side_effect, "idempotency_required": idem_required, "idempotency_key_present": bool(idempotency_key), "decision": decision, "reason": reason, } with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(record) + "\n") # Tamper-evident central audit record (appended + signed by the bash caller). with open(audit_tmp, "w", encoding="utf-8") as f: f.write(json.dumps({ "timestamp": ts, "trace_id": trace_id, "tool": tool_id, "agent": agent, "idempotency_key": idempotency_key, "decision": decision, "reason": reason, "risk_level": risk, "owner": owner, })) print(f"{decision} {reason}") PY )" DECISION="${DECISION_LINE%% *}" REASON="${DECISION_LINE#* }" append_tool_audit "$(cat "$AUDIT_TMP")" "$PROJECT_ROOT" if [[ "$DECISION" == "approved" ]]; then echo "TOOL_APPROVED tool=$TOOL_ID reason=$REASON" else echo "TOOL_DENIED tool=$TOOL_ID reason=$REASON" >&2 exit 2 fi