feat(plan-01): Phase 1 — relocate harness code to packages/casan-harness (symlink facade)
Physically move the pure-code subtrees out of .specify into the package, leaving compat symlinks at the old .specify/<dir> paths so every existing reference (internal CASAN_HARNESS_ROOT + external CI/docker/mjs) keeps resolving. Runtime state stays put. Moved (git mv): scripts/ tests/ security/ templates/ config/ governance/ memory/ .specify/<dir> -> packages/casan-harness/<dir> (+ .specify/<dir> symlink) Stays in .specify (state/governance/domain, handled later): logs/ agentops/ level5/ init-options.json traceability-map.json Python `.resolve()` self-location followed the compat symlink into packages and lost the app root; generate-casan-demo-context.py, generate-agentops-dashboard.py and dashboard-server.py now walk UP for the `.specify` state marker instead of a fixed parent depth (fixes "missing trace files" in run-casan4). Full gate: PASS=64 FAIL=0 SKIP=3 (CASAN_CI_STEP_TIMEOUT_SEC=1200 — track-a ~450s runs close to the 600s default and can tip over under load; this is timing variance, not a regression — it passed cleanly with headroom). Runtime log/audit artifacts kept unstaged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2c765c9a45
commit
664bd1f00c
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 tool registry gate.
|
||||
# Usage:
|
||||
# tool-registry-gate.sh <tool-id>
|
||||
|
||||
TOOL_ID="${1:-}"
|
||||
if [[ -z "$TOOL_ID" ]]; then
|
||||
echo "Usage: tool-registry-gate.sh <tool-id>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/casan-paths.sh"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
REGISTRY="$CASAN_HARNESS_ROOT/level5/tool-registry.yaml"
|
||||
LOG_DIR="$CASAN_STATE_ROOT/logs/level5"
|
||||
AUDIT_DIR="$CASAN_STATE_ROOT/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
|
||||
|
||||
# SEC-10 (M-05): the effective agent identity used for least-privilege authz.
|
||||
# Dev (default) trusts CASAN_AGENT (backward compatible). In enforced mode the env
|
||||
# is NOT trusted — the caller must present a signed token proving it is that agent
|
||||
# (bound to agent id + run id); otherwise it is treated as UNAUTHENTICATED (empty),
|
||||
# which denies any restricted tool. This stops `CASAN_AGENT=release-manager` spoofing.
|
||||
RUN_ID="${CASAN_RUN_ID:-adhoc-$$}"
|
||||
EFFECTIVE_AGENT="${CASAN_AGENT:-}"
|
||||
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_IDENTITY_STRICT:-}" == "1" ]]; then
|
||||
EFFECTIVE_AGENT="" # unauthenticated until a valid token proves otherwise
|
||||
CLAIM="${CASAN_AGENT:-}"
|
||||
SIG="${CASAN_AGENT_SIG:-}"
|
||||
REG="${CASAN_AGENT_REGISTRY:-$CASAN_GOVERNANCE_ROOT/agent-identities.registry}"
|
||||
KEYS_DIR="${CASAN_AGENT_KEYS_DIR:-$CASAN_GOVERNANCE_ROOT/agents}"
|
||||
if [[ -n "$CLAIM" && -n "$SIG" && -f "$SIG" && -f "$REG" ]] && command -v openssl >/dev/null 2>&1; then
|
||||
PUB_REL="$(awk -v id="$CLAIM" '$1=="agent" && $2==id {print $3; exit}' "$REG")"
|
||||
if [[ -n "$PUB_REL" ]]; then
|
||||
PUB="$PUB_REL"; [[ "$PUB" = /* ]] || PUB="$KEYS_DIR/$PUB_REL"
|
||||
TMPM="$(mktemp)"; printf '%s' "casan-agent|v1|$CLAIM|$RUN_ID" > "$TMPM"
|
||||
if [[ -f "$PUB" ]] && openssl dgst -sha256 -verify "$PUB" -signature "$SIG" "$TMPM" >/dev/null 2>&1; then
|
||||
EFFECTIVE_AGENT="$CLAIM"
|
||||
fi
|
||||
rm -f "$TMPM"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
DECISION_LINE="$(python - "$REGISTRY" "$TOOL_ID" "${CASAN_IDEMPOTENCY_KEY:-}" "$LOG_DIR/tool-registry.jsonl" "$TRACE_ID" "$EFFECTIVE_AGENT" "$AUDIT_TMP" "$RUN_ID" <<'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
|
||||
Reference in New Issue
Block a user