195 lines
8.1 KiB
Bash
Executable File
195 lines
8.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# CASAN Level 5 unified harness wrapper.
|
|
# Usage:
|
|
# casan-harness.sh <input-file> <output-file> [action-name] [-- <command> ...]
|
|
#
|
|
# 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 <input-file> <output-file> [action-name] [-- <command> ...]" >&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<warn<info<debug<trace via CASAN_LOG_LEVEL).
|
|
# debug shows each harness phase with its rc; stderr only, stdout untouched.
|
|
# shellcheck source=casan-log.sh
|
|
source "$SCRIPT_DIR/casan-log.sh"
|
|
|
|
# Optional machine-readable phase report (one JSON object per invocation).
|
|
# The Boss orchestrator sets CASAN_PHASE_REPORT per step so it can log per-
|
|
# harness rc at debug level and persist them into pipeline-run.jsonl.
|
|
PHASE_REPORT="${CASAN_PHASE_REPORT:-}"
|
|
PHASE_LOG=""
|
|
CACHE_STATUS="none"
|
|
|
|
record_phase() { # <phase-name> <rc>
|
|
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() { # <phase-name> <command...> — 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}…"
|
|
|
|
# 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" ) ]]; 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
|
|
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_VERIFY_STRICT:-}" == "1" ) \
|
|
&& -f "$SCRIPT_DIR/bundle-integrity.py" ]]; then
|
|
BUNDLE_MANIFEST="${CASAN_BUNDLE_MANIFEST:-$PROJECT_ROOT/.specify/level5/central-governance/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"
|
|
|
|
# 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
|
|
# 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" ) ]]; 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 <<EOF > "$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"
|