feat: casan chat optz

This commit is contained in:
thanhnv
2026-07-19 12:14:12 +07:00
parent 709b6cccd6
commit f462079435
16 changed files with 486 additions and 90 deletions
@@ -25,6 +25,16 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT"
PYTHON_BIN="${CASAN_PYTHON_BIN:-}"
if [[ -z "$PYTHON_BIN" ]]; then
for candidate in /usr/bin/python3 python3 python; do
if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then
PYTHON_BIN="$candidate"
break
fi
done
fi
[[ -n "$PYTHON_BIN" ]] || { echo "ACTION_GATE_RUNTIME_UNAVAILABLE" >&2; exit 69; }
LOG="$CASAN_STATE_ROOT/logs/level5/action-gate.jsonl"
mkdir -p "$(dirname "$LOG")"
# shellcheck source=casan-log.sh
@@ -48,7 +58,7 @@ fi
APPROVER="${CASAN_ACTION_APPROVER:-}"
RESULT="$(CASAN_AG_CMD="$CMD" CASAN_AG_WRITES="$(printf '%s\n' "${WRITES[@]:-}")" python - <<'PY'
RESULT="$(CASAN_AG_CMD="$CMD" CASAN_AG_WRITES="$(printf '%s\n' "${WRITES[@]:-}")" "$PYTHON_BIN" - <<'PY'
import os, re
cmd = os.environ.get("CASAN_AG_CMD", "")
@@ -138,7 +148,7 @@ if [[ "$OUTCOME" == "REQUIRE_APPROVAL" && -n "$APPROVER" ]]; then
EFFECTIVE="ALLOW_APPROVED"
fi
python - "$LOG" "$TS" "$OUTCOME" "$REASON" "$EFFECTIVE" "$APPROVER" "$CMD" <<'PY'
"$PYTHON_BIN" - "$LOG" "$TS" "$OUTCOME" "$REASON" "$EFFECTIVE" "$APPROVER" "$CMD" <<'PY'
import json, sys
log, ts, outcome, reason, eff, approver, cmd = sys.argv[1:]
with open(log, "a", encoding="utf-8") as f:
@@ -14,6 +14,8 @@ import os
import subprocess
import sys
PYTHON_BIN = sys.executable or "/usr/bin/python3"
def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
@@ -154,7 +156,7 @@ def decision_payload(args, decision: str, reason: str, agent=None, skill="", too
def run_rbac(args):
r = subprocess.run([
"python3", RBAC, "check",
PYTHON_BIN, RBAC, "check",
"--role", args.role,
"--resource", "chat",
"--action", "select_agent",
@@ -16,6 +16,8 @@ import tempfile
import uuid
from datetime import datetime, timezone
PYTHON_BIN = sys.executable or "/usr/bin/python3"
GENESIS_HASH = "0" * 64
@@ -137,7 +139,7 @@ def lower(s: str) -> str:
def classify(message: str):
r = subprocess.run(["python3", ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
r = subprocess.run([PYTHON_BIN, ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
if r.returncode != 0:
return {"mode": "BLOCK", "risk": "high", "reason": "router_failed", "matched_rules": [r.stderr.strip()]}
try:
@@ -43,6 +43,7 @@ TENANT_STORE = os.path.join(HARNESS_BIN, "tenant-store.sh")
TENANT_CRYPT = os.path.join(HARNESS_BIN, "tenant-crypt.sh")
MODEL_ROUTER = os.path.join(HARNESS_BIN, "model-router.sh")
CONTEXT_COMPRESS = os.path.join(HARNESS_BIN, "context-compress.py")
PYTHON_BIN = sys.executable or "/usr/bin/python3"
def model_providers_path() -> str:
@@ -126,6 +127,27 @@ def provenance(source: str, path: str, verified=True):
}
def parse_security_verdict(output: str, return_code: int):
trace_match = re.search(r"trace_id=([^\s]+)", output)
risk_match = re.search(r"risk=([^\s]+)", output)
categories_match = re.search(r"categories=([^\s]*)", output)
categories = [item for item in (categories_match.group(1).split(",") if categories_match else []) if item]
if not categories and "semantic-strict-unavailable" in output:
categories = ["semantic-availability"]
elif not categories and "prompt-injection:" in output:
categories = ["prompt-injection"]
elif not categories and "secret-in-input" in output:
categories = ["secret-in-input"]
return {
"gate": "H4",
"outcome": "deny" if return_code != 0 else ("degraded" if "semantic-availability" in categories else "allow"),
"reason_code": "H4_INPUT_BLOCKED" if return_code != 0 else ("H4_SEMANTIC_DEGRADED" if "semantic-availability" in categories else "H4_INPUT_ALLOWED"),
"risk": risk_match.group(1) if risk_match else ("high" if return_code != 0 else "low"),
"trace_id": trace_match.group(1) if trace_match else "",
"categories": categories,
}
def run_security(text: str, mode: str):
with tempfile.TemporaryDirectory() as td:
inp = os.path.join(td, "in.txt")
@@ -136,11 +158,12 @@ def run_security(text: str, mode: str):
safe = ""
if os.path.exists(out):
safe = open(out, encoding="utf-8").read()
return r.returncode, safe.strip(), (r.stdout + r.stderr).strip()
output = (r.stdout + r.stderr).strip()
return r.returncode, safe.strip(), output, parse_security_verdict(output, r.returncode)
def classify(message: str):
r = subprocess.run(["python3", ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
r = subprocess.run([PYTHON_BIN, ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
if r.returncode != 0:
return {"mode": "BLOCK", "risk": "high", "reason": "router_failed", "matched_rules": [r.stderr.strip()]}
try:
@@ -174,7 +197,91 @@ def terms(text: str):
return [t for t in raw if t not in STOPWORDS][:20]
def is_latest_run_query(query: str) -> bool:
normalized = re.sub(r"\s+", " ", query.lower()).strip()
return any(phrase in normalized for phrase in (
"lần chạy cuối", "lần chạy gần nhất", "run cuối", "latest run",
"last run", "most recent run", "bằng chứng lần chạy", "evidence run",
))
def field_line(path: str, field: str) -> int:
try:
with open(path, encoding="utf-8", errors="ignore") as handle:
for line_no, line in enumerate(handle, start=1):
if f'"{field}"' in line:
return line_no
except OSError:
pass
return 1
def collect_latest_goal_sources():
tenant = os.environ.get("CASAN_TENANT_ID", "default")
goals_root = os.path.join(state_root(), "state", "goals", tenant)
candidates = []
if not os.path.isdir(goals_root):
return []
for name in os.listdir(goals_root):
if not name.endswith(".json") or name.endswith(".context.json"):
continue
path = os.path.join(goals_root, name)
try:
with open(path, encoding="utf-8") as handle:
goal = json.load(handle)
if not isinstance(goal, dict) or not goal.get("id"):
continue
candidates.append((str(goal.get("updated_at") or goal.get("created_at") or ""), path, goal))
except (OSError, ValueError):
continue
if not candidates:
return []
_, path, goal = max(candidates, key=lambda item: (item[0], item[1]))
stages = goal.get("stages") if isinstance(goal.get("stages"), list) else []
stage_summary = ", ".join(
f"{stage.get('id', 'stage')}={stage.get('status', 'unknown')}"
for stage in stages if isinstance(stage, dict)
) or "no stage evidence"
verification = goal.get("verification") if isinstance(goal.get("verification"), list) else []
verification_summary = ", ".join(
f"{item.get('command', 'check')} (exit {item.get('exit_code', '?')})"
for item in verification if isinstance(item, dict)
) or "no verification evidence"
patch = goal.get("patch_artifact") if isinstance(goal.get("patch_artifact"), dict) else {}
relative = os.path.relpath(path, ROOT)
envelope = provenance("goal-runtime-state", path, True)
common = {
"source_kind": "latest_goal_run",
"run_id": str(goal.get("id")),
"status": str(goal.get("status") or "unknown"),
"updated_at": str(goal.get("updated_at") or ""),
"project": str(goal.get("project") or "default"),
"result": str(goal.get("result") or ""),
"error": goal.get("error"),
}
return [
{
**common, "path": relative, "line": field_line(path, "status"), "score": 100,
"excerpt": f"Run {goal.get('id')} · status={goal.get('status', 'unknown')} · project={goal.get('project', 'default')} · updated={goal.get('updated_at', '')}",
"envelope": envelope,
},
{
**common, "path": relative, "line": field_line(path, "stages"), "score": 99,
"excerpt": f"Stages: {stage_summary}", "envelope": envelope,
},
{
**common, "path": relative, "line": field_line(path, "verification"), "score": 98,
"excerpt": f"Verification: {verification_summary}; patch={patch.get('status', 'none')} {patch.get('path', '')}".strip(),
"envelope": envelope,
},
]
def collect_sources(query: str):
if is_latest_run_query(query):
runtime_sources = collect_latest_goal_sources()
if runtime_sources:
return runtime_sources
roots = whitelist_roots()
qterms = terms(query)
scored = []
@@ -230,6 +337,23 @@ def collect_sources(query: str):
def answer_from_sources(message: str, sources):
if not sources:
return "Khong tim thay nguon trong whitelist evidence/docs nen khong tra loi suy doan."
if sources[0].get("source_kind") == "latest_goal_run":
source = sources[0]
status = source.get("status", "unknown")
result = source.get("result") or "Không có kết luận được ghi nhận."
error = source.get("error")
details = "\n".join(f"- {item['excerpt']} [{item['path']}:{item['line']}]" for item in sources[:3])
error_line = f"\n- Lỗi cuối: {error}" if error else "\n- Lỗi cuối: không có."
return (
f"Bằng chứng của Goal run gần nhất:\n\n"
f"- Run ID: {source.get('run_id')}\n"
f"- Trạng thái: {status}\n"
f"- Project: {source.get('project')}\n"
f"- Cập nhật cuối: {source.get('updated_at')}"
f"{error_line}\n"
f"- Kết luận: {result}\n\n"
f"Evidence:\n{details}"
)
bullets = []
for s in sources[:3]:
bullets.append(f"- {s['path']}:{s['line']} — {s['excerpt']}")
@@ -454,7 +578,7 @@ def load_history(chat_id: str, tenant_id: str, limit: int = 3) -> str:
raw = "\n".join(f"- user: {u}\n casan: {a}" for u, a in recent)
try:
r = subprocess.run(
["python3", CONTEXT_COMPRESS, "--mode", "structural"],
[PYTHON_BIN, CONTEXT_COMPRESS, "--mode", "structural"],
input=raw, cwd=ROOT, capture_output=True, text=True,
)
if r.returncode == 0 and r.stdout.strip():
@@ -508,6 +632,7 @@ def ask(args):
chat_id = args.chat_id or "chat-default"
turn_id = args.turn_id or f"turn-{trace_id[:12]}"
tenant_id = args.tenant or "default"
security_verdict = None
record_trace_event(trace_id, "H1-context", "running", "Classifying prompt contract")
record_trace_event(trace_id, "H1-context", "pass" if router.get("mode") in ("READ_ONLY", "ANALYSIS") else "blocked", router.get("reason", "Prompt classified"), {
"mode": router.get("mode", "BLOCK"),
@@ -552,6 +677,32 @@ def ask(args):
"decision": decision,
"certified": decision == "ANSWERED",
})
verdict = security_verdict or {
"gate": "H4", "outcome": "not_run", "reason_code": "H4_NOT_RUN",
"risk": router.get("risk", "high"), "trace_id": "", "categories": [],
}
if verdict.get("outcome") == "deny":
remediation = [
"Remove credentials, personal data, or instructions that try to bypass system policy.",
"For security research, describe the goal without pasting an executable jailbreak payload.",
]
summary = "H4 stopped this request at the input boundary. No model or tool received the blocked content."
elif verdict.get("outcome") == "degraded":
remediation = ["Semantic classification is temporarily unavailable; deterministic controls remain active."]
summary = "The request continued in read-only mode with deterministic H4 controls."
else:
remediation = []
summary = "The request passed the H4 input boundary."
governance = {
"outcome": verdict.get("outcome", "not_run"),
"gate": verdict.get("gate", "H4"),
"reason_code": verdict.get("reason_code", "H4_NOT_RUN"),
"summary": summary,
"remediation": remediation,
"categories": verdict.get("categories", []),
"security_trace_id": verdict.get("trace_id", ""),
"side_effect_allowed": bool(router.get("side_effect_allowed", False)),
}
return {
"success": decision == "ANSWERED",
"chat_id": chat_id,
@@ -566,6 +717,7 @@ def ask(args):
"synthesis": synthesis or {"mode": "deterministic"},
"audit": {"seq": rec["seq"], "record_hash": rec["record_hash"], "head": rec["record_hash"]},
"router": router,
"governance": governance,
}
if router.get("mode") not in ("READ_ONLY", "ANALYSIS"):
@@ -576,17 +728,17 @@ def ask(args):
role = "analysis" if router.get("mode") == "ANALYSIS" else "read_only"
record_trace_event(trace_id, "H4-security", "running", "Scanning input boundary")
rc, safe_input, scan_msg = run_security(message, "input")
rc, safe_input, scan_msg, security_verdict = run_security(message, "input")
if rc != 0:
record_trace_event(trace_id, "H4-security", "blocked", "Input rejected by security boundary", {"scan": scan_msg})
record_trace_event(trace_id, "H4-security", "blocked", "Input rejected by security boundary", {"reason_code": security_verdict["reason_code"], "categories": security_verdict["categories"], "security_trace_id": security_verdict["trace_id"]})
router["mode"] = "BLOCK"
router["reason"] = "h4_input_denied"
router["matched_rules"] = router.get("matched_rules", []) + [scan_msg]
answer = "Denied by H4 input scan."
router["matched_rules"] = router.get("matched_rules", []) + security_verdict["categories"]
answer = "H4 đã dừng yêu cầu này trước khi gọi model hoặc công cụ. Hãy bỏ dữ liệu nhạy cảm hoặc diễn đạt lại mục tiêu mà không kèm chỉ dẫn vượt qua chính sách."
print(json.dumps(finish("DENIED", answer), ensure_ascii=False))
return 2
record_trace_event(trace_id, "H4-security", "running", "Input passed; output scan pending", {"input_scan": "pass"})
record_trace_event(trace_id, "H4-security", "running", "Input passed; output scan pending", {"input_scan": security_verdict["outcome"], "categories": security_verdict["categories"], "security_trace_id": security_verdict["trace_id"]})
record_trace_event(trace_id, "H2-tool", "running", "Retrieving allowlisted evidence")
sources = collect_sources(safe_input)
history = load_history(chat_id, tenant_id)
@@ -619,13 +771,15 @@ def ask(args):
"model": synthesis.get("model", "none"),
"source_count": len(sources),
})
rc, safe_answer, scan_msg = run_security(answer, "output")
rc, safe_answer, scan_msg, output_verdict = run_security(answer, "output")
if rc != 0:
record_trace_event(trace_id, "H4-security", "blocked", "Output rejected by security boundary", {"scan": scan_msg})
security_verdict = output_verdict
security_verdict["reason_code"] = "H4_OUTPUT_BLOCKED"
record_trace_event(trace_id, "H4-security", "blocked", "Output rejected by security boundary", {"reason_code": security_verdict["reason_code"], "categories": security_verdict["categories"], "security_trace_id": security_verdict["trace_id"]})
router["mode"] = "BLOCK"
router["reason"] = "h4_output_denied"
router["matched_rules"] = router.get("matched_rules", []) + [scan_msg]
result = finish("DENIED", "Denied by H4 output scan.", sources, safe_input, synthesis)
router["matched_rules"] = router.get("matched_rules", []) + security_verdict["categories"]
result = finish("DENIED", "H4 đã giữ lại phản hồi vì phát hiện nội dung nhạy cảm trong đầu ra. Không có nội dung bị chặn nào được trả về giao diện.", sources, safe_input, synthesis)
if getattr(args, "stream", False):
result["phase"] = "final"
print(json.dumps(result, ensure_ascii=False))
@@ -16,6 +16,7 @@ import os
import subprocess
import sys
PYTHON_BIN = sys.executable or "/usr/bin/python3"
GENESIS_HASH = "0" * 64
@@ -118,7 +119,7 @@ def run_loop_trace(loop_run, cmd: str):
run_id = loop_run.get("run_id")
if not run_id:
return False, "missing_loop_run_id"
args = ["python3", LOOP_TRACE, cmd, "--run-id", run_id]
args = [PYTHON_BIN, LOOP_TRACE, cmd, "--run-id", run_id]
if cmd == "replay":
args += ["--profile", os.environ.get("CASAN_PROFILE", "dev")]
r = subprocess.run(args, cwd=ROOT, capture_output=True, text=True, env=env)
@@ -14,6 +14,8 @@ import tempfile
import uuid
from datetime import datetime, timezone
PYTHON_BIN = sys.executable or "/usr/bin/python3"
def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
@@ -93,7 +95,7 @@ def sha_file(path: str) -> str:
def classify(message: str):
r = subprocess.run(["python3", ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
r = subprocess.run([PYTHON_BIN, ROUTER, "classify", "--message", message], cwd=ROOT, capture_output=True, text=True)
try:
return json.loads(r.stdout)
except Exception:
@@ -332,8 +334,8 @@ def certify_operator_draft(args, router, binding):
"--cost-per-step", "0",
"--context", draft_path,
], cwd=ROOT, capture_output=True, text=True, env=loop_env)
trace_rc = subprocess.run(["python3", LOOP_TRACE, "verify-chain", "--run-id", run_id], cwd=ROOT, capture_output=True, text=True, env=loop_env)
replay_rc = subprocess.run(["python3", LOOP_TRACE, "replay", "--run-id", run_id, "--profile", os.environ.get("CASAN_PROFILE", "dev")], cwd=ROOT, capture_output=True, text=True, env=loop_env)
trace_rc = subprocess.run([PYTHON_BIN, LOOP_TRACE, "verify-chain", "--run-id", run_id], cwd=ROOT, capture_output=True, text=True, env=loop_env)
replay_rc = subprocess.run([PYTHON_BIN, LOOP_TRACE, "replay", "--run-id", run_id, "--profile", os.environ.get("CASAN_PROFILE", "dev")], cwd=ROOT, capture_output=True, text=True, env=loop_env)
certified = r.returncode == 0 and "final=DONE" in r.stdout and trace_rc.returncode == 0 and replay_rc.returncode == 0
return {
"success": certified,
@@ -492,8 +494,8 @@ def certify_codegen_draft(args, router, binding):
"--cost-per-step", "0",
"--context", artifact_path,
], cwd=ROOT, capture_output=True, text=True, env=loop_env)
trace_rc = subprocess.run(["python3", LOOP_TRACE, "verify-chain", "--run-id", run_id], cwd=ROOT, capture_output=True, text=True, env=loop_env)
replay_rc = subprocess.run(["python3", LOOP_TRACE, "replay", "--run-id", run_id, "--profile", os.environ.get("CASAN_PROFILE", "dev")], cwd=ROOT, capture_output=True, text=True, env=loop_env)
trace_rc = subprocess.run([PYTHON_BIN, LOOP_TRACE, "verify-chain", "--run-id", run_id], cwd=ROOT, capture_output=True, text=True, env=loop_env)
replay_rc = subprocess.run([PYTHON_BIN, LOOP_TRACE, "replay", "--run-id", run_id, "--profile", os.environ.get("CASAN_PROFILE", "dev")], cwd=ROOT, capture_output=True, text=True, env=loop_env)
output_scan_rc, output_scan_msg = scan_tool_output(draft, "chat-codegen-output")
certified = (
r.returncode == 0
@@ -602,7 +604,7 @@ def bind_agent(args, router):
if router.get("mode") == "CODEGEN":
tools.extend(["artifact-scan", "tool-output-scan"])
cmd = [
"python3", AGENT_RESOLVER, "bind",
PYTHON_BIN, AGENT_RESOLVER, "bind",
"--agent", agent,
"--actor", args.actor,
"--role", args.role,
@@ -627,7 +629,7 @@ def bind_agent(args, router):
def bind_model(args, binding):
r = subprocess.run([
"python3", MODEL_RESOLVER, "bind",
PYTHON_BIN, MODEL_RESOLVER, "bind",
"--provider", args.model_provider,
"--actor", args.actor,
"--role", args.role,
@@ -654,7 +656,7 @@ def submit_escalation(args, router, binding):
}
reason = f"chat turn requires approval: {binding.get('reason', 'requires_approval')}"
r = subprocess.run([
"python3", APPROVAL_INBOX, "submit",
PYTHON_BIN, APPROVAL_INBOX, "submit",
"--project", args.project,
"--action", "chat.escalate",
"--target", f"chat:{args.chat_id or 'chat-default'}:{turn_id}",
@@ -730,23 +732,23 @@ def ask(args) -> int:
if loop_rc != 0:
denied_from_loop(router, binding, loop_run)
return loop_rc
return run_mode(["python3", OPERATOR, "run", *common], binding, loop_run, "chat-operator")
return run_mode([PYTHON_BIN, OPERATOR, "run", *common], binding, loop_run, "chat-operator")
if router.get("mode") == "CODEGEN":
loop_run, loop_rc = certify_codegen_draft(args, router, binding)
return finish_codegen(args, router, binding, loop_run, loop_rc)
if getattr(args, "stream", False) and router.get("mode") in ("READ_ONLY", "ANALYSIS"):
env = os.environ.copy()
env["CASAN_CHAT_MODEL_PROVIDER"] = model_binding.get("provider", "")
return run_and_passthrough(["python3", READONLY, "ask", "--stream", *common], env)
return run_mode(["python3", READONLY, "ask", *common], binding)
return run_and_passthrough([PYTHON_BIN, READONLY, "ask", "--stream", *common], env)
return run_mode([PYTHON_BIN, READONLY, "ask", *common], binding)
def verify_audit() -> int:
return run_and_passthrough(["python3", READONLY, "verify-audit"])
return run_and_passthrough([PYTHON_BIN, READONLY, "verify-audit"])
def history(args) -> int:
command = ["python3", READONLY, "history", "--actor", args.actor, "--tenant", args.tenant, "--limit", str(args.limit)]
command = [PYTHON_BIN, READONLY, "history", "--actor", args.actor, "--tenant", args.tenant, "--limit", str(args.limit)]
if args.chat_id:
command += ["--chat-id", args.chat_id]
return run_and_passthrough(command)
@@ -27,6 +27,19 @@ set -uo pipefail
# 4 opt-out refused (prod) · 2 usage error.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_BIN="${CASAN_PYTHON_BIN:-}"
if [[ -z "$PYTHON_BIN" ]]; then
for candidate in /usr/bin/python3 python3 python; do
if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then
PYTHON_BIN="$candidate"
break
fi
done
fi
if [[ -z "$PYTHON_BIN" ]]; then
echo "LOOP_RUNTIME_UNAVAILABLE: working Python 3 interpreter not found" >&2
exit 69
fi
GATE="$SCRIPT_DIR/loop-gate.py"
GOV="$SCRIPT_DIR/loop-governor.py"
CONV="$SCRIPT_DIR/loop-convergence.py"
@@ -76,7 +89,7 @@ if [[ "$GOVERNANCE" == "off" ]]; then
exit 4
fi
# An allowed opt-out is always audited (never silent).
PYTHONPATH="$SCRIPT_DIR" python3 - "$RUN_ID" "${CASAN_LOOP_OPTOUT_REASON:-dev-optout}" <<'PY'
PYTHONPATH="$SCRIPT_DIR" "$PYTHON_BIN" - "$RUN_ID" "${CASAN_LOOP_OPTOUT_REASON:-dev-optout}" <<'PY'
import sys, loop_common as lc
lc.append_audit({"kind": "loop_governance_optout", "run_id": sys.argv[1], "reason": sys.argv[2]})
PY
@@ -90,13 +103,13 @@ COST_ACC="0"
for (( step=1; step<=MAX_STEPS; step++ )); do
CUM_TOKENS=$(( CUM_TOKENS + TOKENS_PER_STEP ))
COST_ACC="$(python3 -c "print(round($COST_ACC + $COST_PER_STEP, 6))")"
COST_ACC="$("$PYTHON_BIN" -c "print(round($COST_ACC + $COST_PER_STEP, 6))")"
DECISION="CONTINUE"; VERDICT=""; PROGRESS="0"
if [[ "$GOVERNANCE" == "on" ]]; then
# 1) Governor: cumulative budget check (loop-breaker) BEFORE more work.
set +e
python3 "$GOV" check --run-id "$RUN_ID" --step "$step" \
"$PYTHON_BIN" "$GOV" check --run-id "$RUN_ID" --step "$step" \
--tokens "$CUM_TOKENS" --cost "$COST_ACC" "${prof_args[@]}" >/dev/null 2>&1
grc=$?
set -e 2>/dev/null || true
@@ -106,7 +119,7 @@ for (( step=1; step<=MAX_STEPS; step++ )); do
if [[ "$DECISION" == "CONTINUE" ]]; then
# 2) Gate: per-iteration verify contract.
set +e
GATE_OUT="$(python3 "$GATE" verify --run-id "$RUN_ID" --step "$step" \
GATE_OUT="$("$PYTHON_BIN" "$GATE" verify --run-id "$RUN_ID" --step "$step" \
--artifact "$ARTIFACT" ${CRIT:+--success-criteria "$CRIT"} "${prof_args[@]}" 2>/dev/null)"
set -e 2>/dev/null || true
VERDICT="$(printf '%s' "$GATE_OUT" | json_field verdict)"
@@ -121,10 +134,10 @@ for (( step=1; step<=MAX_STEPS; step++ )); do
# 3) Convergence: observe + verdict (no-progress / oscillation breaker).
if [[ "$DECISION" == "CONTINUE" ]]; then
AH="$(sha_of "$ARTIFACT")"; AH="${AH:0:16}"
python3 "$CONV" observe --run-id "$RUN_ID" --step "$step" \
"$PYTHON_BIN" "$CONV" observe --run-id "$RUN_ID" --step "$step" \
--action-hash "$AH" --progress "$PROGRESS" >/dev/null 2>&1
set +e
python3 "$CONV" verdict --run-id "$RUN_ID" "${prof_args[@]}" >/dev/null 2>&1
"$PYTHON_BIN" "$CONV" verdict --run-id "$RUN_ID" "${prof_args[@]}" >/dev/null 2>&1
cvrc=$?
set -e 2>/dev/null || true
if [[ "$cvrc" -eq 3 ]]; then FINAL="HALT"; DECISION="HALT"; RC=3; fi
@@ -136,7 +149,7 @@ for (( step=1; step<=MAX_STEPS; step++ )); do
fi
# 4) Trace: append the immutable iteration record.
python3 "$TRACE" record --run-id "$RUN_ID" --step "$step" \
"$PYTHON_BIN" "$TRACE" record --run-id "$RUN_ID" --step "$step" \
--intent "turn-$step" --action verify --tool loop-run \
${VERDICT:+--gate-verdict "$VERDICT"} --decision "$DECISION" --progress "$PROGRESS" \
--artifact "$ARTIFACT" ${CRIT:+--success-criteria "$CRIT"} \
@@ -144,7 +157,7 @@ for (( step=1; step<=MAX_STEPS; step++ )); do
# 5) Between-turn context compaction (17.21).
if [[ -n "$CONTEXT" && -f "$CONTEXT" ]]; then
python3 "$COMPRESS" --mode structural --input "$CONTEXT" \
"$PYTHON_BIN" "$COMPRESS" --mode structural --input "$CONTEXT" \
> "$(dirname "$ARTIFACT")/.loop-context-compacted.txt" 2>/dev/null || true
fi
@@ -14,6 +14,16 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT"
PYTHON_BIN="${CASAN_PYTHON_BIN:-}"
if [[ -z "$PYTHON_BIN" ]]; then
for candidate in /usr/bin/python3 python3 python; do
if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then
PYTHON_BIN="$candidate"
break
fi
done
fi
[[ -n "$PYTHON_BIN" ]] || { echo "PATH_GUARD_RUNTIME_UNAVAILABLE" >&2; exit 69; }
TARGET="${1:-}"
ROOT="${2:-$PROJECT_ROOT}"
@@ -22,7 +32,7 @@ if [[ -z "$TARGET" ]]; then
exit 64
fi
python3 - "$TARGET" "$ROOT" <<'PY'
"$PYTHON_BIN" - "$TARGET" "$ROOT" <<'PY'
import os
import sys
@@ -199,7 +199,10 @@ ALERT_PATTERNS=(
)
EMAIL_REGEX='[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
PHONE_REGEX='(\+?[0-9][0-9 .-]{8,}[0-9])'
# Keep the fallback aligned with pii-rules.yaml. The previous separator-heavy
# expression treated ISO dates such as 2026-07-19 as phone numbers and corrupted
# timestamps in certified evidence.
PHONE_REGEX='([+]?[0-9]{9,15})'
PERSONAL_ID_REGEX='\b[0-9]{9,12}\b'
CREDIT_CARD_REGEX='\b([0-9]{4}[- ]?){3}[0-9]{4}\b'
SECRET_REGEX='(API[_-]?KEY|ACCESS[_-]?TOKEN|REFRESH[_-]?TOKEN|PASSWORD|JWT[_-]?SECRET|SECRET)[[:space:]]*[:=][[:space:]]*[^[:space:]]+'
@@ -313,15 +316,20 @@ if [[ "$MODE" == "input" ]]; then
# genuinely novel paraphrase slips through as low-risk, so a still-allowed
# input is routed to the model classifier. Semantic can only ADD a block,
# never remove one. Two modes:
# * CASAN_SECURITY_STRICT=1 — semantic is REQUIRED (V1). If the model is
# unreachable or returns no usable verdict we FAIL CLOSED (block); never a
# silent skip. Off by default so CI without a model stays non-strict.
# * CASAN_SECURITY_STRICT=1 — semantic is REQUIRED (V1). An explicitly
# requested strict scan still fails closed when the classifier is down.
# Production's implicit strict mode uses a risk-based degraded verdict so
# a classifier outage cannot take down every read-only Ask CASAN request.
# * CASAN_SEMANTIC_CLASSIFY=1 (non-strict) — best-effort. On model outage we
# 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_EXPLICIT=0
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; then
STRICT_ON=1
STRICT_EXPLICIT=1
elif [[ -z "${CASAN_SECURITY_STRICT+x}" && "${CASAN_PROFILE:-}" == "prod" ]]; then
STRICT_ON=1
fi
SEMANTIC_REQUIRED=0
@@ -342,13 +350,19 @@ if [[ "$MODE" == "input" ]]; then
MATCHED_RULES+=("semantic-injection")
elif [[ -z "$SEM_VERDICT" ]]; then
# Model unreachable / no usable verdict.
if [[ "$STRICT_ON" == "1" ]]; then
if [[ "$STRICT_EXPLICIT" == "1" || "${CASAN_SECURITY_UNAVAILABLE_POLICY:-risk_based}" == "block" ]]; then
STATUS="blocked"; ACTION="block"; RISK_LEVEL="high"
MATCHED_RULES+=("semantic-strict-unavailable")
casan_log error security "SEMANTIC_STRICT_FAIL_CLOSED trace_id=$TRACE_ID reason=model_unavailable action=block"
else
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
ACTION="alert"
MATCHED_RULES+=("semantic-unavailable")
casan_log warn security "SEMANTIC_SKIPPED trace_id=$TRACE_ID reason=model_unavailable action=keep_regex_verdict hint=set_CASAN_SECURITY_STRICT=1_to_fail_closed"
if [[ "$STRICT_ON" == "1" ]]; then
casan_log warn security "SEMANTIC_DEGRADED trace_id=$TRACE_ID reason=model_unavailable action=keep_deterministic_verdict hint=set_CASAN_SECURITY_UNAVAILABLE_POLICY=block_to_fail_closed"
else
casan_log warn security "SEMANTIC_SKIPPED trace_id=$TRACE_ID reason=model_unavailable action=keep_regex_verdict hint=set_CASAN_SECURITY_STRICT=1_to_fail_closed"
fi
fi
fi
fi
@@ -389,6 +403,12 @@ fi
INPUT_HASH="$(printf '%s' "$CONTENT" | hash_text)"
OUTPUT_HASH="$(printf '%s' "$SAFE_CONTENT" | hash_text)"
RULES_JSON="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | "$PYTHON_BIN" -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
CATEGORIES_CSV="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | "$PYTHON_BIN" -c 'import sys; rules=sys.stdin.read().splitlines(); cats=[]
for rule in rules:
cat = (rule.split(":", 1)[0] if ":" in rule else rule).replace("semantic-strict-unavailable", "semantic-availability").replace("semantic-unavailable", "semantic-availability")
if cat and cat not in cats: cats.append(cat)
print(",".join(cats))')"
CATEGORIES_JSON="$(printf '%s' "$CATEGORIES_CSV" | "$PYTHON_BIN" -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().split(",") if x]))')"
TRACE_FILE="$TRACE_DIR/security-$TRACE_ID.json"
cat > "$TRACE_FILE" <<EOF
@@ -401,13 +421,14 @@ cat > "$TRACE_FILE" <<EOF
"action": "$ACTION",
"risk_level": "$RISK_LEVEL",
"matched_rules": $RULES_JSON,
"matched_categories": $CATEGORIES_JSON,
"input_hash": "$INPUT_HASH",
"output_hash": "$OUTPUT_HASH"
}
EOF
printf '{"timestamp":"%s","trace_id":"%s","harness":"H4-security","mode":"%s","status":"%s","action":"%s","risk_level":"%s","input_hash":"%s","output_hash":"%s"}\n' \
"$TIMESTAMP" "$TRACE_ID" "$MODE" "$STATUS" "$ACTION" "$RISK_LEVEL" "$INPUT_HASH" "$OUTPUT_HASH" >> "$AUDIT_DIR/security.jsonl"
printf '{"timestamp":"%s","trace_id":"%s","harness":"H4-security","mode":"%s","status":"%s","action":"%s","risk_level":"%s","matched_categories":%s,"input_hash":"%s","output_hash":"%s"}\n' \
"$TIMESTAMP" "$TRACE_ID" "$MODE" "$STATUS" "$ACTION" "$RISK_LEVEL" "$CATEGORIES_JSON" "$INPUT_HASH" "$OUTPUT_HASH" >> "$AUDIT_DIR/security.jsonl"
if [[ "$STATUS" == "blocked" ]]; then
: > "$OUTPUT_FILE"
@@ -417,4 +438,4 @@ fi
printf '%s\n' "$SAFE_CONTENT" > "$OUTPUT_FILE"
STATUS_UPPER="$(printf '%s' "$STATUS" | tr '[:lower:]' '[:upper:]')"
echo "SECURITY_${STATUS_UPPER} trace_id=$TRACE_ID risk=$RISK_LEVEL action=$ACTION output=$OUTPUT_FILE"
echo "SECURITY_${STATUS_UPPER} trace_id=$TRACE_ID risk=$RISK_LEVEL action=$ACTION categories=$CATEGORIES_CSV output=$OUTPUT_FILE"