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>
154 lines
6.0 KiB
Bash
Executable File
154 lines
6.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -uo pipefail
|
|
|
|
# CASAN H4 — Benign / false-positive budget report (Track A, A6).
|
|
#
|
|
# A security gate that over-blocks is abandoned by the team, so hardening must
|
|
# be measured against BOTH attacks and legitimate inputs. This runs the REAL
|
|
# security-check.sh (deterministic pattern layer, semantic disabled) over:
|
|
# * a benign corpus (VI/JA/EN) -> any block is a FALSE POSITIVE
|
|
# * the red-team vector set -> a block is a true positive
|
|
# and reports block_rate + false_positive_rate, then enforces the budget:
|
|
# * false_positive_rate <= FP_MAX (default 3%)
|
|
# * adversarial block_rate >= BLOCK_MIN (default 95%)
|
|
# * CRITICAL vectors block_rate == 100%
|
|
#
|
|
# The "strict" FP threshold refers to this 3% budget, measured on the
|
|
# deterministic layer (semantic OFF) so it is reproducible in CI without a model.
|
|
#
|
|
# Usage: benign-fp-report.sh [--no-gate] [output.json]
|
|
# Exit: 0 within budget (or --no-gate), 1 budget exceeded, 64 usage.
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/casan-paths.sh"
|
|
PROJECT_ROOT="$CASAN_APP_ROOT"
|
|
SEC_DIR="$CASAN_HARNESS_ROOT/security"
|
|
CORPUS_DIR="$CASAN_DOMAIN_ROOT/corpus/benign-corpus"
|
|
VECTORS="$CASAN_DOMAIN_ROOT/corpus/redteam-vectors.jsonl"
|
|
|
|
GATE=1
|
|
OUT_JSON="$PROJECT_ROOT/docs/output/casan/benign-fp-report.json"
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--no-gate) GATE=0 ;;
|
|
*.json) OUT_JSON="$arg" ;;
|
|
*) echo "Usage: benign-fp-report.sh [--no-gate] [output.json]" >&2; exit 64 ;;
|
|
esac
|
|
done
|
|
mkdir -p "$(dirname "$OUT_JSON")"
|
|
|
|
FP_MAX="${CASAN_FP_MAX_PCT:-3.0}"
|
|
BLOCK_MIN="${CASAN_ADV_BLOCK_MIN_PCT:-95.0}"
|
|
|
|
CASAN_FP_GATE="$GATE" CASAN_FP_MAX="$FP_MAX" CASAN_BLOCK_MIN="$BLOCK_MIN" \
|
|
CASAN_OUT_JSON="$OUT_JSON" CASAN_CORPUS_DIR="$CORPUS_DIR" CASAN_VECTORS="$VECTORS" \
|
|
CASAN_SC="$SCRIPT_DIR/security-check.sh" \
|
|
python - <<'PY'
|
|
import json, os, subprocess, tempfile
|
|
|
|
corpus_dir = os.environ["CASAN_CORPUS_DIR"]
|
|
vectors_path = os.environ["CASAN_VECTORS"]
|
|
sc = os.environ["CASAN_SC"]
|
|
fp_max = float(os.environ["CASAN_FP_MAX"])
|
|
block_min = float(os.environ["CASAN_BLOCK_MIN"])
|
|
gate = os.environ["CASAN_FP_GATE"] == "1"
|
|
out_json = os.environ["CASAN_OUT_JSON"]
|
|
|
|
def is_blocked(text: str) -> bool:
|
|
# Run the REAL control on the deterministic layer (semantic OFF). rc==2 == blocked.
|
|
with tempfile.TemporaryDirectory() as d:
|
|
inp = os.path.join(d, "in.txt")
|
|
out = os.path.join(d, "out.txt")
|
|
with open(inp, "w", encoding="utf-8") as f:
|
|
f.write(text if text.endswith("\n") else text + "\n")
|
|
env = dict(os.environ, CASAN_SECURITY_STRICT="0", CASAN_SEMANTIC_CLASSIFY="0")
|
|
rc = subprocess.run(["bash", sc, inp, out, "input"], env=env,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode
|
|
return rc == 2
|
|
|
|
# --- Benign corpus: any block is a false positive ---
|
|
benign = {}
|
|
fp_examples = []
|
|
for lang in ("en", "vi", "ja"):
|
|
path = os.path.join(corpus_dir, f"{lang}.txt")
|
|
total = blocked = 0
|
|
if os.path.isfile(path):
|
|
for line in open(path, encoding="utf-8"):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
total += 1
|
|
if is_blocked(line):
|
|
blocked += 1
|
|
fp_examples.append({"lang": lang, "text": line[:80]})
|
|
benign[lang] = {"total": total, "false_positives": blocked}
|
|
|
|
benign_total = sum(v["total"] for v in benign.values())
|
|
benign_fp = sum(v["false_positives"] for v in benign.values())
|
|
fp_rate = (100.0 * benign_fp / benign_total) if benign_total else 0.0
|
|
|
|
# --- Red-team vectors: a block is a true positive ---
|
|
adv_total = adv_blocked = 0
|
|
crit_total = crit_blocked = 0
|
|
missed = []
|
|
if os.path.isfile(vectors_path):
|
|
for line in open(vectors_path, encoding="utf-8"):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
v = json.loads(line)
|
|
adv_total += 1
|
|
is_crit = v.get("severity") == "critical"
|
|
if is_crit:
|
|
crit_total += 1
|
|
b = is_blocked(v["text"])
|
|
if b:
|
|
adv_blocked += 1
|
|
if is_crit:
|
|
crit_blocked += 1
|
|
else:
|
|
missed.append({"id": v.get("id"), "severity": v.get("severity"), "desc": v.get("desc")})
|
|
|
|
block_rate = (100.0 * adv_blocked / adv_total) if adv_total else 0.0
|
|
crit_rate = (100.0 * crit_blocked / crit_total) if crit_total else 100.0
|
|
|
|
report = {
|
|
"generated": "deterministic (semantic OFF)",
|
|
"policy": {"fp_max_pct": fp_max, "adv_block_min_pct": block_min, "critical_block_pct": 100.0},
|
|
"benign": {"by_lang": benign, "total": benign_total, "false_positives": benign_fp,
|
|
"false_positive_rate_pct": round(fp_rate, 2), "examples": fp_examples},
|
|
"adversarial": {"total": adv_total, "blocked": adv_blocked,
|
|
"block_rate_pct": round(block_rate, 2), "missed": missed},
|
|
"critical": {"total": crit_total, "blocked": crit_blocked,
|
|
"block_rate_pct": round(crit_rate, 2)},
|
|
}
|
|
|
|
# Budget evaluation.
|
|
breaches = []
|
|
if fp_rate > fp_max:
|
|
breaches.append(f"false_positive_rate {fp_rate:.2f}% > budget {fp_max}%")
|
|
if block_rate < block_min:
|
|
breaches.append(f"adversarial_block_rate {block_rate:.2f}% < floor {block_min}%")
|
|
if crit_rate < 100.0:
|
|
breaches.append(f"critical_block_rate {crit_rate:.2f}% < required 100%")
|
|
report["within_budget"] = not breaches
|
|
report["breaches"] = breaches
|
|
|
|
with open(out_json, "w", encoding="utf-8") as f:
|
|
json.dump(report, f, indent=2, ensure_ascii=False)
|
|
|
|
print(f"BENIGN_FP_REPORT benign={benign_total} fp={benign_fp} fp_rate={fp_rate:.2f}% "
|
|
f"adv={adv_total} blocked={adv_blocked} block_rate={block_rate:.2f}% "
|
|
f"critical={crit_blocked}/{crit_total} ({crit_rate:.2f}%)")
|
|
print(f" policy: FP<={fp_max}% adv_block>={block_min}% critical=100%")
|
|
print(f" report: {out_json}")
|
|
for m in missed:
|
|
print(f" MISSED_VECTOR id={m['id']} severity={m['severity']} desc={m['desc']}")
|
|
for b in breaches:
|
|
print(f" BUDGET_BREACH {b}")
|
|
|
|
if breaches and gate:
|
|
raise SystemExit(1)
|
|
print("BENIGN_FP_WITHIN_BUDGET" if not breaches else "BENIGN_FP_REPORT_ONLY (--no-gate)")
|
|
PY
|