feat: plan 16 P2 batch 2 (SEC-12 drift, SEC-29 audit fail-closed, SEC-30 replay, SEC-15 low)

- SEC-12: drift-detect adds semantic invariants — negation-flip detection (a dropped
  "not" now FAILS despite high char-similarity) + env must-keep patterns.
- SEC-29 (X-05): governance-check audit write fails CLOSED — an unwritable audit log
  denies the action and empties the output (no unaudited output).
- SEC-30 (X-06): approval-verify records a one-time-use nonce (sha of token/sig) and
  rejects replays (enforced mode / when a nonce ledger is set); dev unchanged.
- SEC-15 (low): typosquat distance<=2 with the levenshtein length-sentinel bug fixed
  (no false positives); tool-exec fails closed with no timeout backend in enforced
  mode; validate-tool-input now validates nested objects/arrays recursively.

Verify: new SEC suites all green via gate, run-casan4 0-FAIL, adversarial 44/44,
track-c 29/0, h5-approval 12/0, no regressions.

Plan-16 P2 remaining: infra-gated only (SEC-14/22/23/24/25/26).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-06 22:55:24 +09:00
co-authored by Claude Opus 4.8
parent 8c06a55aed
commit d695a598ee
14 changed files with 365 additions and 30 deletions
@@ -72,18 +72,52 @@ 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(),