fix: harden Codex hook finalization

This commit is contained in:
thanhnv
2026-07-24 13:06:23 +07:00
parent bfebfa9d39
commit 34a6b013c0
8 changed files with 79 additions and 24 deletions
+5
View File
@@ -130,6 +130,11 @@ casan level show
Với Codex, mở `/hooks`, review và trust đúng project hook sau lần init hoặc khi Với Codex, mở `/hooks`, review và trust đúng project hook sau lần init hoặc khi
bootstrap hash thay đổi. bootstrap hash thay đổi.
Codex hooks gọi bootstrap tương đối từ project root và không phụ thuộc vào
`git rev-parse`, nên ownership hoặc cấu hình Git không thể làm hỏng lifecycle
hook. Git vẫn được khuyến nghị mạnh cho source provenance, review diff và
rollback trước khi cho agent thực hiện side effect.
### 4. Dùng workflow hiện có ### 4. Dùng workflow hiện có
Tiếp tục dùng chat, agents, skills và slash commands của project như bình Tiếp tục dùng chat, agents, skills và slash commands của project như bình
+1 -1
View File
@@ -1 +1 @@
1.0.0 1.0.1
+3 -1
View File
@@ -155,7 +155,9 @@ hiện tại.
Bootstrap `.casan/casan-hook.py` tự đọc `config.json`; developer không còn phải Bootstrap `.casan/casan-hook.py` tự đọc `config.json`; developer không còn phải
source `.casan/agentic.env` trước khi mở IDE. Mỗi invocation resolve Core theo source `.casan/agentic.env` trước khi mở IDE. Mỗi invocation resolve Core theo
runtime mode rồi kiểm tra live hash so với `version.lock` trước khi chạy runtime mode rồi kiểm tra live hash so với `version.lock` trước khi chạy
adapter. adapter. Codex gọi bootstrap bằng đường dẫn tương đối từ project root, không
phụ thuộc `git rev-parse`; Git vẫn được khuyến nghị để có provenance, diff và
rollback đáng tin cậy.
### Project đã có `.claude`, `.github`, agents, skills hoặc CASAN vendored ### Project đã có `.claude`, `.github`, agents, skills hoặc CASAN vendored
@@ -1,13 +1,13 @@
{ {
"description": "CASAN Plan-20 lifecycle hooks. Review with /hooks; the project bootstrap resolves and verifies the pinned global harness.", "description": "CASAN lifecycle hooks. Review with /hooks; the project bootstrap resolves and verifies the pinned Core runtime.",
"hooks": { "hooks": {
"UserPromptSubmit": [ "UserPromptSubmit": [
{ {
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event UserPromptSubmit", "command": "python3 \".casan/casan-hook.py\" --client codex --event UserPromptSubmit",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event UserPromptSubmit", "commandWindows": "py -3 \".casan\\casan-hook.py\" --client codex --event UserPromptSubmit",
"timeout": 15, "timeout": 15,
"statusMessage": "CASAN admission" "statusMessage": "CASAN admission"
} }
@@ -20,8 +20,8 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PreToolUse", "command": "python3 \".casan/casan-hook.py\" --client codex --event PreToolUse",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PreToolUse", "commandWindows": "py -3 \".casan\\casan-hook.py\" --client codex --event PreToolUse",
"timeout": 15, "timeout": 15,
"statusMessage": "CASAN policy gate" "statusMessage": "CASAN policy gate"
} }
@@ -34,8 +34,8 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PostToolUse", "command": "python3 \".casan/casan-hook.py\" --client codex --event PostToolUse",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PostToolUse", "commandWindows": "py -3 \".casan\\casan-hook.py\" --client codex --event PostToolUse",
"timeout": 15, "timeout": 15,
"statusMessage": "CASAN evidence" "statusMessage": "CASAN evidence"
} }
@@ -47,8 +47,8 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event Stop", "command": "python3 \".casan/casan-hook.py\" --client codex --event Stop",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event Stop", "commandWindows": "py -3 \".casan\\casan-hook.py\" --client codex --event Stop",
"timeout": 15, "timeout": 15,
"statusMessage": "CASAN finalize" "statusMessage": "CASAN finalize"
} }
@@ -164,13 +164,15 @@ echo "$CLAUDE_PRE" | grep -q '"permissionDecision": "allow"' && pass "Claude gen
CODEX_BEGIN=$(printf '%s' '{"session_id":"hybrid-codex","turn_id":"t2","prompt":"review this project","cwd":"'"$PROJ"'"}' | CODEX_BEGIN=$(printf '%s' '{"session_id":"hybrid-codex","turn_id":"t2","prompt":"review this project","cwd":"'"$PROJ"'"}' |
CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client codex --event UserPromptSubmit) CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client codex --event UserPromptSubmit)
echo "$CODEX_BEGIN" | grep -q '"continue": true' && pass "Codex generated hook opens admission" || fail "Codex generated hook failed ($CODEX_BEGIN)" echo "$CODEX_BEGIN" | grep -q '"continue": true' && pass "Codex generated hook opens admission" || fail "Codex generated hook failed ($CODEX_BEGIN)"
python3 - "$PROJ/.codex/hooks.json" <<'PY' && pass "Codex hook JSON matches the current nested command schema" || fail "Codex hook JSON schema is stale" python3 - "$PROJ/.codex/hooks.json" <<'PY' && pass "Codex hooks use the current schema and project-relative bootstrap" || fail "Codex hook contract is stale"
import json,sys import json,sys
d=json.load(open(sys.argv[1])) d=json.load(open(sys.argv[1]))
handlers=[h for groups in d["hooks"].values() for group in groups for h in group["hooks"]] handlers=[h for groups in d["hooks"].values() for group in groups for h in group["hooks"]]
assert handlers and all(h.get("type") == "command" for h in handlers) assert handlers and all(h.get("type") == "command" for h in handlers)
assert all(isinstance(h.get("command"), str) for h in handlers) assert all(isinstance(h.get("command"), str) for h in handlers)
assert all(isinstance(h.get("timeout"), int) and "timeout_ms" not in h for h in handlers) assert all(isinstance(h.get("timeout"), int) and "timeout_ms" not in h for h in handlers)
assert all('python3 ".casan/casan-hook.py"' in h["command"] for h in handlers)
assert all("git rev-parse" not in h["command"] for h in handlers)
PY PY
echo "===== ③ version.lock pins the installed harness =====" echo "===== ③ version.lock pins the installed harness ====="
@@ -1,13 +1,13 @@
{ {
"description": "CASAN Plan-20 lifecycle hooks. Review with /hooks; the project bootstrap resolves and verifies the pinned global harness.", "description": "CASAN lifecycle hooks. Review with /hooks; the project bootstrap resolves and verifies the pinned Core runtime.",
"hooks": { "hooks": {
"UserPromptSubmit": [ "UserPromptSubmit": [
{ {
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event UserPromptSubmit", "command": "python3 \".casan/casan-hook.py\" --client codex --event UserPromptSubmit",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event UserPromptSubmit", "commandWindows": "py -3 \".casan\\casan-hook.py\" --client codex --event UserPromptSubmit",
"timeout": 15, "timeout": 15,
"statusMessage": "CASAN admission" "statusMessage": "CASAN admission"
} }
@@ -20,8 +20,8 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PreToolUse", "command": "python3 \".casan/casan-hook.py\" --client codex --event PreToolUse",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PreToolUse", "commandWindows": "py -3 \".casan\\casan-hook.py\" --client codex --event PreToolUse",
"timeout": 15, "timeout": 15,
"statusMessage": "CASAN policy gate" "statusMessage": "CASAN policy gate"
} }
@@ -34,8 +34,8 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PostToolUse", "command": "python3 \".casan/casan-hook.py\" --client codex --event PostToolUse",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PostToolUse", "commandWindows": "py -3 \".casan\\casan-hook.py\" --client codex --event PostToolUse",
"timeout": 15, "timeout": 15,
"statusMessage": "CASAN evidence" "statusMessage": "CASAN evidence"
} }
@@ -47,8 +47,8 @@
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event Stop", "command": "python3 \".casan/casan-hook.py\" --client codex --event Stop",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event Stop", "commandWindows": "py -3 \".casan\\casan-hook.py\" --client codex --event Stop",
"timeout": 15, "timeout": 15,
"statusMessage": "CASAN finalize" "statusMessage": "CASAN finalize"
} }
@@ -857,14 +857,39 @@ def op_finalize(req):
# summary as the closing verification control. # summary as the closing verification control.
reasons = [] reasons = []
if req.get("assistant_summary"): if req.get("assistant_summary"):
ok, why = h4_scan(req.get("assistant_summary"), "output") h4 = h4_scan(req.get("assistant_summary"), "output")
add_evidence(rec, "H3/H7", "finalize-verify", "allow" if ok else "flag", why) if h4 == "ok":
if not ok: add_evidence(
rec, "H3/H7", "finalize-verify", "allow", "h4_ok")
elif h4 == "unavailable":
# An output that could not be scanned must never be certified.
# Match begin/pre-tool graceful degradation: keep the developer
# workflow available, but make the missing control explicit.
rec["integration_mode"] = "observed_only"
rec["certification_strength"] = "observed_only"
add_evidence(
rec, "H3/H7", "finalize-verify", "degraded",
"gate_unavailable_no_bash")
reasons.append("output_gate_unavailable")
else:
# A policy block or timeout is fail-closed for certification.
# Treat unknown statuses the same way so a future contract change
# cannot silently certify an unverified output.
why = {
"blocked": "h4_blocked",
"timeout": "h4_internal_timeout",
}.get(h4, "h4_unknown_status")
add_evidence(
rec, "H3/H7", "finalize-verify", "flag", why)
reasons.append("output_flagged:%s" % why) reasons.append("output_flagged:%s" % why)
# Certification decision. # Certification decision.
strength = rec.get("certification_strength", "observed_only") strength = rec.get("certification_strength", "observed_only")
certified = True certified = True
if reasons:
# Any closing output-verification failure above is certification-fatal,
# even when every earlier admission/tool control succeeded.
certified = False
if enforcement_mode() != "enforce": if enforcement_mode() != "enforce":
certified = False certified = False
reasons.append("observe_mode") reasons.append("observe_mode")
@@ -116,6 +116,27 @@ METRICS=$(grep -c '"harness":"H6-agentic"' "$CASAN_STATE_ROOT/logs/cost/metrics.
[[ "$(printf '%s' "$F2" | field reason)" == "already_finalized" && "$METRICS" == "1" ]] \ [[ "$(printf '%s' "$F2" | field reason)" == "already_finalized" && "$METRICS" == "1" ]] \
&& pass "second finalize is idempotent (one metric only)" || fail "finalize not idempotent (reason=$(printf '%s' "$F2" | field reason) metrics=$METRICS)" && pass "second finalize is idempotent (one metric only)" || fail "finalize not idempotent (reason=$(printf '%s' "$F2" | field reason) metrics=$METRICS)"
# ── C8b: output scan status contract is handled without finalize crashes ────
echo "===== C8b: finalize handles blocked and unavailable output scans ====="
newstate
B=$(bridge '{"op":"begin","client":"codex","project":"'"$PROJ"'","session":"c8b-block","prompt":"summarize the work","integration_mode":"project_hook"}')
AID=$(printf '%s' "$B" | field admission_id)
F=$(bridge '{"op":"finalize","admission_id":"'"$AID"'","stop_reason":"completed","assistant_summary":"API_KEY=supersecret"}')
[[ "$(printf '%s' "$F" | field decision)" == "non_certified" ]] \
&& printf '%s' "$F" | field reason | grep -q "output_flagged:h4_blocked" \
&& pass "blocked assistant output finalizes as non-certified without crashing" \
|| fail "blocked assistant output finalize contract failed ($F)"
newstate
B=$(bridge '{"op":"begin","client":"codex","project":"'"$PROJ"'","session":"c8b-unavailable","prompt":"summarize the work","integration_mode":"project_hook"}')
AID=$(printf '%s' "$B" | field admission_id)
F=$(CASAN_AGENTIC_BASH=/nonexistent/bash-xyz bridge '{"op":"finalize","admission_id":"'"$AID"'","stop_reason":"completed","assistant_summary":"completed safely"}')
[[ "$(printf '%s' "$F" | field decision)" == "non_certified" ]] \
&& [[ "$(printf '%s' "$F" | field certification_strength)" == "observed_only" ]] \
&& printf '%s' "$F" | field reason | grep -q "output_gate_unavailable" \
&& pass "unavailable output gate degrades finalize without crashing" \
|| fail "unavailable output gate finalize contract failed ($F)"
# ── C10: token/cost unavailable -> null + warning, never 0 ─────────────────── # ── C10: token/cost unavailable -> null + warning, never 0 ───────────────────
echo "===== C10: missing token/cost = null + partial warning, not zero =====" echo "===== C10: missing token/cost = null + partial warning, not zero ====="
newstate newstate