#!/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)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" TMP_DIR="$PROJECT_ROOT/.specify/logs/tmp" CACHE_DIR="$PROJECT_ROOT/.specify/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 } run_phase() { # — preserves the failing rc exactly local phase="$1"; shift local rc=0 "$@" || rc=$? record_phase "$phase" "$rc" if [[ "$rc" -ne 0 ]]; then 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}" 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)" 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}…" 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" # H2 tool registry gate is in the line of fire for side-effecting actions: # it enforces idempotency key, per-agent permission, and rollback strategy # before the command is allowed to execute. The wrapper already derived a # content-addressed idempotency key above. case "$ACTION_NAME" in write_code|migration|deploy|db_write|external_api|write_file) run_phase "H2-gate" env CASAN_IDEMPOTENCY_KEY="$IDEMPOTENCY_KEY" "$SCRIPT_DIR/tool-registry-gate.sh" "$ACTION_NAME" ;; esac # 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" run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \ "$SCRIPT_DIR/tool-exec.sh" "$TOOL_TIMEOUT" -- "$@" else CACHE_STATUS="stored" run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" 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 if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; 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 write_phase_report casan_log debug harness "action=$ACTION_NAME complete cache=$CACHE_STATUS" echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY output=$FINAL_OUTPUT"