Files
CASAN/packages/casan-harness/scripts/bash/drift-detect.sh
T
thanhnvandClaude Opus 4.8 36a4812ef3 refactor(structure): promote app to repo root + remove redundant workspace cruft
Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.

- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
  .specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
  active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
  launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
  tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
  README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
  artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
  - .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
    working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
    -> packages/casan-harness/... (.specify/logs state kept)
  - .claude/launch.json, .gitea/*-runbook.md: path prefixes
  - CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
  - policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.

Full gate from the new root: PASS=64 FAIL=0 SKIP=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:26:36 +09:00

136 lines
4.7 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# CASAN Level 5 drift detector.
# Usage:
# drift-detect.sh <golden-file> <candidate-file> <report-json>
GOLDEN="${1:-}"
CANDIDATE="${2:-}"
REPORT="${3:-}"
if [[ -z "$GOLDEN" || -z "$CANDIDATE" || -z "$REPORT" ]]; then
echo "Usage: drift-detect.sh <golden-file> <candidate-file> <report-json>" >&2
exit 64
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT"
mkdir -p "$(dirname "$REPORT")" "$CASAN_STATE_ROOT/logs/level5"
python - "$GOLDEN" "$CANDIDATE" "$REPORT" <<'PY'
import difflib
import hashlib
import json
import os
import pathlib
import sys
from datetime import datetime, timezone
golden_path = pathlib.Path(sys.argv[1])
candidate_path = pathlib.Path(sys.argv[2])
report_path = pathlib.Path(sys.argv[3])
# SEC-09 (M-10): cap input size (SequenceMatcher is O(n^2) → DoS) and read
# fail-closed. A missing/oversize/undecodable input yields a BLOCK verdict, never
# a crash/traceback (which under set -e would abort ambiguously) and never a silent
# pass.
MAX_BYTES = int(os.environ.get("CASAN_MAX_INPUT_BYTES", str(2 * 1024 * 1024)))
def block(reason):
report_path.parent.mkdir(parents=True, exist_ok=True)
report = {
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"harness": "L5-drift-detection", "status": "fail", "action": "block",
"reason": reason, "golden_file": str(golden_path), "candidate_file": str(candidate_path),
}
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"DRIFT_FAIL reason={reason} report={report_path}")
raise SystemExit(2)
def read_capped(path):
try:
size = path.stat().st_size
except OSError:
block(f"unreadable:{path.name}")
if size > MAX_BYTES:
block(f"oversize:{path.name}({size}>{MAX_BYTES})")
try:
# errors="replace" so non-UTF8 bytes degrade to a marker instead of crashing.
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
block(f"unreadable:{path.name}")
golden = read_capped(golden_path)
candidate = read_capped(candidate_path)
similarity = difflib.SequenceMatcher(None, golden, candidate).ratio()
length_delta = abs(len(candidate) - len(golden)) / max(len(golden), 1)
status = "pass"
action = "allow"
reasons = []
if similarity < 0.70 or length_delta > 0.50:
status = "fail"
action = "block_or_fallback"
reasons.append("low_similarity_or_length_delta")
elif similarity < 0.85 or length_delta > 0.30:
status = "warn"
action = "require_review"
# SEC-12: char-similarity alone misses SEMANTIC inversion — dropping a negation
# ("must NOT deploy" -> "must deploy") keeps similarity high but flips meaning. A
# candidate that removes negation tokens present in the golden is treated as drift.
import re as _re
NEG = _re.compile(
r"\b(not|no|never|cannot|can't|don't|must not|mustn't|deny|denied|reject|disable|"
r"disabled|forbid|prohibit|block|blocked|không|đừng|cấm|từ chối)\b",
_re.IGNORECASE,
)
golden_neg = len(NEG.findall(golden))
cand_neg = len(NEG.findall(candidate))
if golden_neg > cand_neg:
# The dangerous case: looks nearly identical but a negation vanished.
status, action = "fail", "block_or_fallback"
reasons.append(f"negation_dropped(golden={golden_neg},candidate={cand_neg})")
# must-keep invariants: regex patterns that MUST still appear in the candidate.
_mk = os.environ.get("CASAN_DRIFT_MUSTKEEP_FILE", "")
missing = []
if _mk and os.path.isfile(_mk):
for _line in open(_mk, encoding="utf-8", errors="replace"):
_pat = _line.strip()
if _pat and not _re.search(_pat, candidate):
missing.append(_pat)
if missing:
status, action = "fail", "block_or_fallback"
reasons.append(f"must_keep_missing={len(missing)}")
report = {
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"harness": "L5-drift-detection",
"status": status,
"action": action,
"reasons": reasons,
"golden_negations": golden_neg,
"candidate_negations": cand_neg,
"must_keep_missing": missing,
"similarity_ratio": round(similarity, 4),
"length_delta_ratio": round(length_delta, 4),
"golden_hash": hashlib.sha256(golden.encode()).hexdigest(),
"candidate_hash": hashlib.sha256(candidate.encode()).hexdigest(),
"golden_file": str(golden_path),
"candidate_file": str(candidate_path),
}
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"DRIFT_{status.upper()} similarity={report['similarity_ratio']} length_delta={report['length_delta_ratio']} report={report_path}")
if status == "fail":
raise SystemExit(2)
PY