#!/usr/bin/env bash set -euo pipefail # CASAN Level 5 unified harness wrapper. # Usage: # casan-harness.sh [action-name] [-- ...] # # Flow: # H4 input security -> H5 governance -> H6 metrics around execution/cache -> H4 output filter INPUT_FILE="${1:-}" FINAL_OUTPUT="${2:-}" ACTION_NAME="${3:-agent_step}" shift 3 || true if [[ "${1:-}" == "--" ]]; then shift fi if [[ -z "$INPUT_FILE" || -z "$FINAL_OUTPUT" ]]; then echo "Usage: casan-harness.sh [action-name] [-- ...]" >&2 exit 64 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" PROJECT_ROOT="$CASAN_APP_ROOT" KERNEL_CLI="$CASAN_HARNESS_ROOT/scripts/python/kernel_cli.py" # SEC-23 (MT-01): make state (control-plane settings, telemetry, audit) tenant-scoped # when CASAN_TENANT_ID is set, so a run for tenant A never touches tenant B's state. # No-op when no tenant is set (baseline unchanged); invalid tenant fails closed. # shellcheck source=tenant-paths.sh source "$SCRIPT_DIR/tenant-paths.sh" TMP_DIR="$CASAN_STATE_ROOT/logs/tmp" CACHE_DIR="$CASAN_STATE_ROOT/logs/idempotency" mkdir -p "$TMP_DIR" "$CACHE_DIR" "$(dirname "$FINAL_OUTPUT")" # Shared log taxonomy (error casan_log debug harness "action=$ACTION_NAME phase=$1 rc=$2" PHASE_LOG="${PHASE_LOG:+$PHASE_LOG,}{\"phase\":\"$1\",\"rc\":$2}" } write_phase_report() { [[ -n "$PHASE_REPORT" ]] || return 0 printf '{"action":"%s","cache":"%s","phases":[%s]}\n' \ "$ACTION_NAME" "$CACHE_STATUS" "$PHASE_LOG" > "$PHASE_REPORT" 2>/dev/null || true } emit_failed_kernel() { # phase rc — best effort, never masks the original failure local failed_phase="$1" failed_rc="$2" [[ -f "$KERNEL_CLI" ]] || return 0 local run_id="${EXECUTION_ID:-native-failed-${TRACE_SUFFIX:-$$}}" local mode="${NATIVE_MODE:-observe}" local event bundle path event="$(CASAN_NATIVE_RISK="${ACTION_RISK_JSON:-}" python3 - "$run_id" "$ACTION_NAME" "$mode" "$failed_phase" "$failed_rc" "${CASAN_ACTOR:-${CASAN_AGENT:-}}" <<'PY' import json, os, sys run_id, action, mode, phase, rc, actor = sys.argv[1:] try: risk = json.loads(os.environ.get("CASAN_NATIVE_RISK") or "{}") except ValueError: risk = {} print(json.dumps({ "run_id": run_id, "correlation_id": run_id, "action": action, "tool": action, "actor": actor, "mode": mode, "environment": os.environ.get("CASAN_PROFILE", "development"), "execution_status": "failed", "assurance_status": "failed", "certification_status": "non_certified", "policy_decisions": [risk] if risk else [], "extensions": {"failed_phase": phase, "exit_code": int(rc)}, }, separators=(",", ":"))) PY )" || return 0 bundle="$(printf '%s' "$event" | python3 "$KERNEL_CLI" adapt-native - 2>/dev/null)" || return 0 path="$CASAN_STATE_ROOT/logs/kernel/$run_id.json" CASAN_KERNEL_BUNDLE="$bundle" python3 - "$path" <<'PY' || return 0 import json, os, sys, tempfile path = sys.argv[1]; payload = json.loads(os.environ["CASAN_KERNEL_BUNDLE"]) os.makedirs(os.path.dirname(path), exist_ok=True) fd, tmp = tempfile.mkstemp(prefix=".kernel-", dir=os.path.dirname(path)) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle, sort_keys=True, separators=(",", ":")); handle.write("\n") handle.flush(); os.fsync(handle.fileno()) os.replace(tmp, path) finally: try: os.unlink(tmp) except OSError: pass PY } run_phase() { # — preserves the failing rc exactly local phase="$1"; shift local rc=0 "$@" || rc=$? record_phase "$phase" "$rc" if [[ "$rc" -ne 0 ]]; then emit_failed_kernel "$phase" "$rc" write_phase_report exit "$rc" fi } hash_text() { if command -v sha256sum >/dev/null 2>&1; then sha256sum | awk '{print $1}' else shasum -a 256 | awk '{print $1}' fi } CMD_STR="${*:-no_cmd}" NATIVE_MODE="${CASAN_ENFORCEMENT_MODE:-}" if [[ -z "$NATIVE_MODE" ]]; then if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then NATIVE_MODE="enforce"; else NATIVE_MODE="observe"; fi fi case "$NATIVE_MODE" in observe|enforce) : ;; *) NATIVE_MODE="enforce"; casan_log error harness "INVALID_ENFORCEMENT_MODE fail_closed=enforce" ;; esac INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)" CMD_HASH="$(printf '%s' "$CMD_STR" | hash_text)" IDEMPOTENCY_KEY="$(printf '%s|%s|%s' "$INPUT_HASH" "$CMD_HASH" "$ACTION_NAME" | hash_text)" EXECUTION_ID="${CASAN_EXECUTION_ID:-native-${IDEMPOTENCY_KEY:0:24}}" export CASAN_EXECUTION_ID="$EXECUTION_ID" CACHE_META="$CACHE_DIR/$IDEMPOTENCY_KEY.json" CACHE_OUT="$CACHE_DIR/$IDEMPOTENCY_KEY.output" TRACE_SUFFIX="$(date +%s)-$$" SAFE_INPUT="$TMP_DIR/security-input-$TRACE_SUFFIX.txt" APPROVED_INPUT="$TMP_DIR/governance-approved-$TRACE_SUFFIX.txt" RAW_OUTPUT="$TMP_DIR/raw-output-$TRACE_SUFFIX.txt" casan_log debug harness "action=$ACTION_NAME input=$INPUT_FILE output=$FINAL_OUTPUT key=${IDEMPOTENCY_KEY:0:12}…" # C7: honor an engaged kill-switch before doing any work (incident containment). # Opt-in (default off) so the baseline is unchanged. SEC-17 (ARCH-03): under # CASAN_PROFILE=prod it defaults ON (secure-by-default); an explicit =0 still wins. if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" || ( -z "${CASAN_KILLSWITCH_ENFORCE+x}" && ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ) ) ]]; then KS_SCOPE="${CASAN_KILLSWITCH_SCOPE:-project}" KS_ID="${CASAN_KILLSWITCH_ID:-${CASAN_PROJECT:-current}}" if ! bash "$SCRIPT_DIR/kill-switch.sh" check "$KS_SCOPE" "$KS_ID" >/dev/null 2>&1; then casan_log error harness "KILL_SWITCH_ACTIVE scope=$KS_SCOPE id=$KS_ID — refusing to run $ACTION_NAME" : > "$FINAL_OUTPUT" echo "KILL_SWITCH_ACTIVE scope=$KS_SCOPE id=$KS_ID action=$ACTION_NAME" >&2 exit 2 fi # SEC-23 (MT-03): a tenant-scoped switch halts ONLY its own tenant (noisy-neighbor # isolation) — tenant A's emergency stop must not stop tenant B. if [[ -n "${CASAN_TENANT_ID:-}" ]] \ && ! bash "$SCRIPT_DIR/kill-switch.sh" check tenant "$CASAN_TENANT_ID" >/dev/null 2>&1; then casan_log error harness "KILL_SWITCH_ACTIVE scope=tenant id=$CASAN_TENANT_ID — refusing to run $ACTION_NAME" : > "$FINAL_OUTPUT" echo "KILL_SWITCH_ACTIVE scope=tenant id=$CASAN_TENANT_ID action=$ACTION_NAME" >&2 exit 2 fi fi # SEC-16 (ARCH-01): in enforced mode, verify the harness+policy bundle against its # signed manifest and REFUSE to run on any drift — editing a gate/policy is a bypass # that leaves no input trace. Only active when a manifest is provisioned (so dev and # prod-without-a-manifest are unaffected); a present-but-drifted bundle fails closed. if [[ ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" || "${CASAN_VERIFY_STRICT:-}" == "1" ) \ && -f "$SCRIPT_DIR/bundle-integrity.py" ]]; then BUNDLE_MANIFEST="${CASAN_BUNDLE_MANIFEST:-$CASAN_GOVERNANCE_ROOT/harness-bundle-manifest.json}" if [[ -f "$BUNDLE_MANIFEST" ]]; then if ! python "$SCRIPT_DIR/bundle-integrity.py" verify >/dev/null 2>&1; then casan_log error harness "BUNDLE_INTEGRITY_DRIFT — refusing to run $ACTION_NAME (harness/policy modified vs signed manifest)" : > "$FINAL_OUTPUT" echo "BUNDLE_INTEGRITY_DRIFT action=$ACTION_NAME (harness or policy modified vs signed manifest)" >&2 exit 2 fi fi fi run_phase "H4-in" "$SCRIPT_DIR/security-check.sh" "$INPUT_FILE" "$SAFE_INPUT" input run_phase "H5" "$SCRIPT_DIR/governance-check.sh" "$SAFE_INPUT" "$APPROVED_INPUT" "$ACTION_NAME" # Canonical action classification decides whether the H2 registry and isolated # executor are required. A classifier failure is treated as unknown/high-risk. ACTION_RISK_JSON="" ACTION_RISK_RC=0 ACTION_RISK_JSON="$(python3 "$KERNEL_CLI" risk --action "$ACTION_NAME" --tool "$ACTION_NAME" \ --command "$CMD_STR" --content-file "$SAFE_INPUT" --actor "${CASAN_ACTOR:-${CASAN_AGENT:-}}" \ --environment "${CASAN_PROFILE:-development}" 2>/dev/null)" || ACTION_RISK_RC=$? if [[ "$ACTION_RISK_RC" -eq 0 && -n "$ACTION_RISK_JSON" ]]; then ACTION_FIELDS="$(python3 - "$ACTION_RISK_JSON" <<'PY' import json, sys p=json.loads(sys.argv[1]) print("%s\t%s" % ("1" if p["side_effecting"] else "0", p["action_class"])) PY )" || ACTION_RISK_RC=$? fi if [[ "$ACTION_RISK_RC" -eq 0 && -n "${ACTION_FIELDS:-}" ]]; then IFS=$'\t' read -r SIDE_EFFECTING ACTION_CLASS <<< "$ACTION_FIELDS" else SIDE_EFFECTING=1 ACTION_CLASS="unknown" casan_log error harness "ACTION_CLASSIFIER_FAILED_CLOSED action=$ACTION_NAME" fi if [[ "$SIDE_EFFECTING" == "1" ]]; then REGISTRY_ARGS=(registry-config --mode "$NATIVE_MODE" --profile "${CASAN_PROFILE:-development}" \ --evidence-log "$CASAN_STATE_ROOT/logs/policy/h2-registry-config.jsonl") [[ -n "${CASAN_H2_REGISTRY+x}" ]] && REGISTRY_ARGS+=(--explicit "$CASAN_H2_REGISTRY") REGISTRY_RC=0 REGISTRY_JSON="$(python3 "$KERNEL_CLI" "${REGISTRY_ARGS[@]}")" || REGISTRY_RC=$? if [[ "$REGISTRY_RC" -ne 0 ]]; then record_phase "H2-config" "$REGISTRY_RC" echo "H2_REGISTRY_CONFIGURATION_DENIED $REGISTRY_JSON" >&2 write_phase_report exit "$REGISTRY_RC" fi REGISTRY_ENABLED="$(python3 -c 'import json,sys; print("1" if json.load(sys.stdin)["enabled"] else "0")' <<< "$REGISTRY_JSON")" if [[ "$REGISTRY_ENABLED" == "1" ]]; then H2_ACTION="$ACTION_NAME" case "$ACTION_CLASS" in write) H2_ACTION="write_file" ;; delete) H2_ACTION="delete_file" ;; database_mutation) H2_ACTION="db_write" ;; deployment|release|infrastructure_modification) H2_ACTION="deploy" ;; external_network_side_effect) H2_ACTION="external_api" ;; unknown) H2_ACTION="unknown_tool" ;; esac run_phase "H2-gate" python3 "$KERNEL_CLI" h2-gate \ --gate "${CASAN_H2_GATE_PATH:-$SCRIPT_DIR/tool-registry-gate.sh}" --mode "$NATIVE_MODE" \ --actor "${CASAN_ACTOR:-${CASAN_AGENT:-}}" --action "$H2_ACTION" --tool "$ACTION_NAME" \ --execution-id "$EXECUTION_ID" --enforcement-path "native_harness.pre_execution.h2_registry" \ --idempotency-key "$IDEMPOTENCY_KEY" --timeout "${CASAN_H2_GATE_TIMEOUT_SECONDS:-8}" \ --evidence-log "$CASAN_STATE_ROOT/logs/policy/h2-decisions.jsonl" else casan_log warn harness "HIGH H2 registry unsafe development/test bypass active; run is non-certifiable" fi fi # T4: propagate step name so any nested model calls (model-call.py) log against the same step # name, enabling provider-cost-lookup.py to match real Ollama token counts in agent-metrics.sh. export CASAN_STEP_NAME="${CASAN_STEP_NAME:-$ACTION_NAME}" # WP-S5: wrap command execution under a hard wall-clock timeout (tool-exec.sh). # Prevents runaway or hung tool calls from blocking the pipeline indefinitely. TOOL_TIMEOUT="${CASAN_TOOL_TIMEOUT_SECONDS:-30}" if [[ -f "$CACHE_META" && -f "$CACHE_OUT" ]]; then CACHE_STATUS="cached" run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- bash -c 'cp "$1" "$CASAN_OUTPUT"' _ "$CACHE_OUT" elif [[ "$#" -gt 0 ]]; then CACHE_STATUS="stored" if [[ "$SIDE_EFFECTING" == "1" && "$NATIVE_MODE" == "enforce" ]]; then run_phase "H6-exec" env CASAN_ENFORCEMENT_MODE="$NATIVE_MODE" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \ "$SCRIPT_DIR/sandbox-run.sh" --workspace "$PROJECT_ROOT" --timeout "$TOOL_TIMEOUT" -- "$@" else run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \ "$SCRIPT_DIR/tool-exec.sh" "$TOOL_TIMEOUT" -- "$@" fi else CACHE_STATUS="stored" run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" fi TOOL_OUTPUT_MAX_BYTES="${CASAN_TOOL_OUTPUT_MAX_BYTES:-1048576}" if [[ ! "$TOOL_OUTPUT_MAX_BYTES" =~ ^[1-9][0-9]*$ ]]; then casan_log error harness "TOOL_OUTPUT_LIMIT_INVALID value=$TOOL_OUTPUT_MAX_BYTES" : > "$FINAL_OUTPUT" exit 2 fi RAW_OUTPUT_BYTES="$(wc -c < "$RAW_OUTPUT" | tr -d ' ')" if [[ "$RAW_OUTPUT_BYTES" -gt "$TOOL_OUTPUT_MAX_BYTES" ]]; then record_phase "H4-output-size" 2 casan_log error harness "TOOL_OUTPUT_QUARANTINED bytes=$RAW_OUTPUT_BYTES limit=$TOOL_OUTPUT_MAX_BYTES" : > "$FINAL_OUTPUT" write_phase_report echo "TOOL_OUTPUT_QUARANTINED reason=output_size_limit bytes=$RAW_OUTPUT_BYTES limit=$TOOL_OUTPUT_MAX_BYTES" >&2 exit 2 fi # V7: tool output can carry indirect injection that would re-enter a downstream # model's context. Scan RAW_OUTPUT for injection/secret patterns before it is # reused. Mode: off | warn (default) | block. Strict mode upgrades to block. # warn keeps existing behaviour (logged, non-blocking) so benign drafts are not # broken; block enforces (fail-closed) for production/strict runs. TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}" if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then # SEC-17/M-02: prod profile defaults tool-output scanning to block (fail-closed). if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ) ) ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi fi if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then TOS_RC=0 "$SCRIPT_DIR/tool-output-scan.sh" "$RAW_OUTPUT" "$ACTION_NAME" >/dev/null 2>&1 || TOS_RC=$? record_phase "H4-tool-output" "$TOS_RC" if [[ "$TOS_RC" -eq 2 ]]; then if [[ "$TOOL_OUTPUT_SCAN_MODE" == "block" ]]; then casan_log error harness "TOOL_OUTPUT_INJECTION_BLOCKED action=$ACTION_NAME" : > "$FINAL_OUTPUT" write_phase_report echo "TOOL_OUTPUT_INJECTION_BLOCKED action=$ACTION_NAME" >&2 exit 2 else casan_log warn harness "TOOL_OUTPUT_INJECTION_SUSPECTED action=$ACTION_NAME mode=warn hint=set_CASAN_TOOL_OUTPUT_SCAN=block_or_CASAN_SECURITY_STRICT=1_to_enforce" fi fi fi run_phase "H4-out" "$SCRIPT_DIR/security-check.sh" "$RAW_OUTPUT" "$FINAL_OUTPUT" output if [[ "$CACHE_STATUS" == "stored" ]]; then cat < "$CACHE_META" { "idempotency_key": "$IDEMPOTENCY_KEY", "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", "action": "$ACTION_NAME", "command": "$(printf '%s' "$CMD_STR" | sed 's/"/\\"/g')", "output_hash": "$(cat "$FINAL_OUTPUT" | hash_text)" } EOF cp "$FINAL_OUTPUT" "$CACHE_OUT" fi # Dual-emit the framework-independent kernel envelope. Legacy phase reports and # outputs remain unchanged; the canonical contract is an additive artifact. TRUST_JSON="$(python3 "$KERNEL_CLI" trust-capabilities 2>/dev/null || true)" RUN_CERTIFICATION="non_certified" RUN_ASSURANCE="passed" [[ "$NATIVE_MODE" == "observe" ]] && RUN_ASSURANCE="degraded" if [[ "$NATIVE_MODE" == "enforce" && -n "$TRUST_JSON" ]]; then TRUST_CERTIFIABLE="$(python3 -c 'import json,sys; print("1" if json.load(sys.stdin).get("certifiable") else "0")' <<< "$TRUST_JSON" 2>/dev/null || echo 0)" REGISTRY_BYPASS="$(printf '%s' "${REGISTRY_JSON:-{}}" | python3 -c 'import json,sys; print("1" if json.load(sys.stdin).get("unsafe_bypass") else "0")' 2>/dev/null || echo 0)" [[ "$REGISTRY_BYPASS" == "1" ]] && RUN_ASSURANCE="degraded" [[ "$TRUST_CERTIFIABLE" == "1" && "$REGISTRY_BYPASS" == "0" ]] && RUN_CERTIFICATION="certified" fi NATIVE_EVENT="$(CASAN_NATIVE_RISK="$ACTION_RISK_JSON" python3 - "$EXECUTION_ID" "$ACTION_NAME" "$ACTION_CLASS" "$NATIVE_MODE" "$RUN_ASSURANCE" "$RUN_CERTIFICATION" "${CASAN_ACTOR:-${CASAN_AGENT:-}}" "$INPUT_HASH" "$CMD_HASH" <<'PY' import json, os, sys run_id, action, action_class, mode, assurance, certification, actor, input_hash, command_hash = sys.argv[1:] try: risk = json.loads(os.environ.get("CASAN_NATIVE_RISK") or "{}") except ValueError: risk = {} print(json.dumps({ "run_id": run_id, "correlation_id": run_id, "action": action, "tool": action, "actor": actor, "mode": mode, "environment": os.environ.get("CASAN_PROFILE", "development"), "execution_status": "success", "assurance_status": assurance, "certification_status": certification, "policy_decisions": [risk] if risk else [], "extensions": {"input_hash": input_hash, "command_hash": command_hash, "action_class": action_class}, }, separators=(",", ":"))) PY )" KERNEL_RC=0 KERNEL_BUNDLE="$(printf '%s' "$NATIVE_EVENT" | python3 "$KERNEL_CLI" adapt-native -)" || KERNEL_RC=$? if [[ "$KERNEL_RC" -ne 0 ]]; then casan_log error harness "KERNEL_CONTRACT_EMISSION_FAILED rc=$KERNEL_RC" if [[ "$NATIVE_MODE" == "enforce" ]]; then : > "$FINAL_OUTPUT" exit 2 fi RUN_CERTIFICATION="non_certified" else KERNEL_PATH="$CASAN_STATE_ROOT/logs/kernel/$EXECUTION_ID.json" python3 - "$KERNEL_PATH" "$KERNEL_BUNDLE" <<'PY' import json, os, sys, tempfile path, raw = sys.argv[1:] os.makedirs(os.path.dirname(path), exist_ok=True) payload = json.loads(raw) fd, tmp = tempfile.mkstemp(prefix=".kernel-", dir=os.path.dirname(path)) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle, sort_keys=True, separators=(",", ":")) handle.write("\n") handle.flush(); os.fsync(handle.fileno()) os.replace(tmp, path) finally: try: os.unlink(tmp) except OSError: pass PY fi write_phase_report casan_log debug harness "action=$ACTION_NAME complete cache=$CACHE_STATUS" echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY execution=success assurance=$RUN_ASSURANCE certification=$RUN_CERTIFICATION output=$FINAL_OUTPUT"