#!/usr/bin/env bash set -uo pipefail # CASAN Track C-MVP — Tool authorization / ACTION gating (C1, V17). # # tool-registry-gate.sh authorizes by tool NAME + agent. This complements it by # inspecting the ACTION itself — the command to run and the files it would # write — because a legitimately-named tool can still attempt an illegitimate # action (overwrite .env, curl|bash, rm -rf /, exfiltrate to an unknown host). # # Outcome model (per Plan-07 C0): ALLOW | WARN | REQUIRE_APPROVAL | BLOCK. # BLOCK sensitive-file write, destructive/remote-exec command -> exit 2 # REQUIRE_APPROVAL network egress, dependency install -> exit 3 # (becomes ALLOW+audit when CASAN_ACTION_APPROVER is set) # WARN noteworthy but permitted -> exit 0 (logged) # ALLOW ordinary action -> exit 0 # # Usage: # action-gate.sh --command "" [--write ...] # action-gate.sh -- [args...] # # BLOCK is never overridable. REQUIRE_APPROVAL clears only with an explicit # approver identity (audited) — approval is never silent. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" PROJECT_ROOT="$CASAN_APP_ROOT" LOG="$CASAN_STATE_ROOT/logs/level5/action-gate.jsonl" mkdir -p "$(dirname "$LOG")" # shellcheck source=casan-log.sh source "$SCRIPT_DIR/casan-log.sh" CMD="" WRITES=() while [[ "$#" -gt 0 ]]; do case "$1" in --command) CMD="${2:-}"; shift 2 ;; --write) WRITES+=("${2:-}"); shift 2 ;; --) shift; CMD="$*"; break ;; *) CMD="${CMD:+$CMD }$1"; shift ;; esac done if [[ -z "$CMD" && "${#WRITES[@]}" -eq 0 ]]; then echo "Usage: action-gate.sh --command \"\" [--write ...] | -- " >&2 exit 64 fi APPROVER="${CASAN_ACTION_APPROVER:-}" RESULT="$(CASAN_AG_CMD="$CMD" CASAN_AG_WRITES="$(printf '%s\n' "${WRITES[@]:-}")" python - <<'PY' import os, re cmd = os.environ.get("CASAN_AG_CMD", "") writes = [w for w in os.environ.get("CASAN_AG_WRITES", "").splitlines() if w] low = cmd.lower() # Sensitive-write targets (BLOCK). Checked against both explicit --write paths # and any path-looking token in the command. SENSITIVE = [ r"(^|/)\.env(\.[a-z]+)?$", r"\.pem$", r"\.key$", r"(^|/)id_rsa$", r"(^|/)id_ed25519$", r"\.p12$", r"\.pfx$", r"(^|/)\.ssh/", r"(^|/)\.github/workflows/", r"(^|/)\.git/hooks/", r"(^|/)\.aws/credentials", r"(^|/)\.npmrc$", r"(^|/)\.pypirc$", r"(^|/)\.netrc$", r"^/etc/", r"(^|/)\.dockercfg", r"(^|/)docker/config\.json$", ] # Destructive / remote-exec commands (BLOCK). DANGEROUS = [ r"\brm\s+-rf?\s+(/|~|\$home|/\*|\.\s*$|\.\s|\*)", r":\(\)\s*\{\s*:\s*\|\s*:", r"\bmkfs\b", r"\bdd\b[^\n]*of=/dev/", r"\bchmod\s+(-r\s+)?777\b", r">\s*/dev/sd", r"\bgit\s+push\b[^\n]*(--force|\s-f\b)", r"(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(ba)?sh\b", r"\bshred\b\s+/", r"\bchown\s+-r\s+root", ] # Dependency install (REQUIRE_APPROVAL) — imperative form; C2 covers manifest diffs. DEP_INSTALL = [ r"\bnpm\s+(install|i|add)\s+\S", r"\byarn\s+add\s+\S", r"\bpnpm\s+add\s+\S", r"\bpip3?\s+install\s+\S", r"\bpoetry\s+add\s+\S", r"\bgem\s+install\s+\S", r"\bgo\s+get\s+\S", r"\bcargo\s+add\s+\S", r"\b(apt|apt-get|apk|brew|dnf|yum)\s+install\s+\S", ] # Network egress (REQUIRE_APPROVAL) unless clearly local. EGRESS = [r"\bcurl\b", r"\bwget\b", r"\bnc\b", r"\bncat\b", r"\bscp\b", r"\brsync\b[^\n]*::", r"\bssh\b\s+\S"] LOCAL_OK = [r"127\.0\.0\.1", r"localhost", r"0\.0\.0\.0", r"::1", r"/api/tags"] def any_match(pats, text): return next((p for p in pats if re.search(p, text)), None) # 1. Sensitive writes -> BLOCK for path in writes + re.findall(r"[\w./~-]+", cmd): m = any_match(SENSITIVE, path.lower()) if m: print(f"BLOCK|sensitive_file_write:{path}") raise SystemExit(0) # 2. Destructive / remote-exec -> BLOCK m = any_match(DANGEROUS, low) if m: print(f"BLOCK|dangerous_command:{m}") raise SystemExit(0) # 3. Dependency install -> REQUIRE_APPROVAL m = any_match(DEP_INSTALL, low) if m: print(f"REQUIRE_APPROVAL|dependency_install:{m}") raise SystemExit(0) # 4. Network egress -> REQUIRE_APPROVAL unless clearly local if any_match(EGRESS, low) and not any_match(LOCAL_OK, low): print("REQUIRE_APPROVAL|network_egress") raise SystemExit(0) # 5. sudo (non-destructive) -> WARN if re.search(r"\bsudo\b", low): print("WARN|privilege_escalation") raise SystemExit(0) print("ALLOW|ok") PY )" PY_RC=$? # SEC-04 (H-05): fail CLOSED. Previously, if the classifier crashed / was killed / # produced no output, RESULT was empty, OUTCOME was "", and the final case fell # through to ALLOW (fail-open). Now a non-zero classifier RC, empty output, or an # unrecognized verdict all default to BLOCK — an action is allowed only on an # explicit, recognized ALLOW/WARN/REQUIRE_APPROVAL verdict. if [[ "$PY_RC" -ne 0 || -z "$RESULT" ]]; then OUTCOME="BLOCK"; REASON="classifier_error(rc=$PY_RC)" else OUTCOME="${RESULT%%|*}"; REASON="${RESULT#*|}" fi case "$OUTCOME" in ALLOW|WARN|REQUIRE_APPROVAL|BLOCK) : ;; *) OUTCOME="BLOCK"; REASON="unrecognized_verdict:${OUTCOME:-empty}" ;; esac TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" # Approval clears REQUIRE_APPROVAL only with an explicit (audited) approver. EFFECTIVE="$OUTCOME" if [[ "$OUTCOME" == "REQUIRE_APPROVAL" && -n "$APPROVER" ]]; then EFFECTIVE="ALLOW_APPROVED" fi python - "$LOG" "$TS" "$OUTCOME" "$REASON" "$EFFECTIVE" "$APPROVER" "$CMD" <<'PY' import json, sys log, ts, outcome, reason, eff, approver, cmd = sys.argv[1:] with open(log, "a", encoding="utf-8") as f: f.write(json.dumps({ "timestamp": ts, "harness": "C1-action-gate", "outcome": outcome, "effective": eff, "reason": reason, "approver": approver, "command": cmd[:400], }) + "\n") PY case "$EFFECTIVE" in BLOCK) casan_log error action-gate "ACTION_BLOCKED reason=$REASON" echo "ACTION_GATE outcome=BLOCK reason=$REASON" >&2 exit 2 ;; REQUIRE_APPROVAL) casan_log warn action-gate "ACTION_REQUIRES_APPROVAL reason=$REASON (set CASAN_ACTION_APPROVER= to approve)" echo "ACTION_GATE outcome=REQUIRE_APPROVAL reason=$REASON" >&2 exit 3 ;; ALLOW_APPROVED) echo "ACTION_GATE outcome=ALLOW reason=approved_by:$APPROVER ($REASON)" exit 0 ;; WARN) casan_log warn action-gate "ACTION_WARN reason=$REASON" echo "ACTION_GATE outcome=WARN reason=$REASON" exit 0 ;; ALLOW) echo "ACTION_GATE outcome=ALLOW reason=$REASON" exit 0 ;; *) # SEC-04: fail-closed default — never ALLOW on an unexpected/empty verdict. casan_log error action-gate "ACTION_GATE_FAILCLOSED outcome=${EFFECTIVE:-empty} reason=$REASON" echo "ACTION_GATE outcome=BLOCK reason=failclosed:${EFFECTIVE:-empty} ($REASON)" >&2 exit 2 ;; esac