From 3fab8b59c29aa61fba8f0cbc7d147ca7910284ff Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 11:41:30 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EA=B0=80?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=20=ED=9B=85=20=ED=8E=98=EC=9D=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=EB=8C=80=EC=83=81=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20=EC=9D=BD=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code 의 PreToolUse 페이로드는 도구 입력을 tool_input 아래에 중첩해서 보내는데, gate_check.sh 는 최상위 키에서만 file_path 를 찾았다. 경로 추출에 실패하면 fail-open(exit 0) 하므로, facts/accepted.dl · facts/query.dl 신선도 게이트가 프로덕션에서 한 번도 발동하지 않았다. tests/test_gate_check.sh 는 평평한 픽스처만 보내서 전 케이스 PASS 하며 이 결함을 통과시켰다. 추출기를 다시 썼다. - tool_input 을 먼저 보고 최상위로 폴백한다. 최상위 file_path 를 보내는 실제 페이로드는 없다 — 이 폴백은 하니스의 평평한 픽스처 호환용이다. - tool_name · 경로 · tool_input 종류를 각각 독립된 try/except 로 뽑고, 어떤 실패에도 정확히 세 필드를 낸다. 부분 출력에 의존하지 않는다. - 필드 구분자는 NUL 이다. 경로에 개행이 들어갈 수 있고, 개행 구분은 그런 경로를 다른 경로로 판단하거나 합법적인 쓰기에 거부를 오발한다. - notebook_path 는 방어용이다. hooks.json 의 matcher "Write|Edit" 는 완전일치로 비교되므로 NotebookEdit·MultiEdit 는 이 훅에 도달하지 않는다. fail-closed 는 좁게 둔다. tool_input 이 JSON 객체로 존재하고 tool_name 이 Write·Edit 완전일치인데 경로를 전혀 읽지 못한 경우에만 거부한다. 파싱 실패, tool_name 부재, 목록 밖 도구, 객체가 아닌 tool_input 은 모두 통과시킨다 — 파일 두 개를 지키는 게이트가 전역 Write/Edit 중단이 되어서는 안 된다. 이 거부 하나에만 FACTLOG_GATE_FAIL_OPEN=1 탈출구를 두고 거부 메시지에 안내한다. exit 2 의 stderr 는 모델에게 되돌아가므로 복구 수단이 없으면 재시도 루프가 된다. 이 커밋부터 게이트가 프로덕션에서 처음 실제로 발화한다. 엔진 입력이 이미 있고 리포트가 없거나 낡은 사용자는 이제부터 하드 거부를 받으며, /factlog check 로 리포트를 갱신해야 엔진 입력을 편집할 수 있다. 하니스에 중첩 페이로드 케이스를 추가했다. 수정 전 실패하던 것: 리포트 부재, stale 리포트, 경로 키 전무, 키 우선순위 양방향, 탈출구가 신선도 거부를 풀지 않음. 나머지는 수정 전 게이트가 전부 허용했으므로 vacuous 이며 주석에 그렇게 표시했다. 업스트림 #323 --- hooks/gate_check.sh | 137 +++++++++++++++++++--- tests/test_gate_check.sh | 247 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+), 13 deletions(-) diff --git a/hooks/gate_check.sh b/hooks/gate_check.sh index 9e7faebb..95c1eb61 100755 --- a/hooks/gate_check.sh +++ b/hooks/gate_check.sh @@ -23,6 +23,10 @@ # DENY (exit 2) otherwise, i.e. TARGET is an engine input AND NOT bootstrap # AND (report absent OR report stale). # +# TARGET itself is read from the hook payload; when it cannot be read at all +# the predicate above is undefined, and the narrow fail-closed rule described +# under "fail-closed branches" below decides instead. +# # This predicate is falsifiable in both directions: # - Bootstrap is allowed: creating facts/query.dl in a freshly `factlog init` # KB (no logic_report.txt, no pre-existing query.dl) returns exit 0. @@ -48,10 +52,24 @@ # That degrade is made OBSERVABLE: when Python is available but the resolver # returns empty (package import failure), a one-line stderr note is emitted so # the silent permissive fallback is visible to an operator (see below). -# The only true fail-closed here is the python-availability check below (which -# DENYs when no usable Python 3.11+ is present, since the predicate cannot then -# be evaluated). Target-path extraction failures for engine-input-shaped payloads -# likewise deny. +# There are exactly TWO fail-closed branches, both with an env escape hatch: +# 1. The python-availability check below DENYs when no usable Python 3.11+ is +# present, since the predicate cannot then be evaluated. Escape hatch: +# FACTLOG_PYTHON (point it at a usable interpreter). +# 2. Target-path extraction DENYs only in the narrow case where the payload +# carries a `tool_input` JSON OBJECT, `tool_name` is one of the write-class +# tools this hook is registered for, and NO usable path can be read from +# either `tool_input` or the top level. That combination means the payload +# schema drifted out from under the extractor while a write was in flight, +# so the predicate cannot be evaluated for a write that may well target an +# engine input. Escape hatch: FACTLOG_GATE_FAIL_OPEN=1 (named in the deny +# message, since a PreToolUse exit 2 stderr is fed back to the model and +# without a stated recovery it becomes a retry loop). +# Everything else fails OPEN (exit 0): unparseable payloads, a missing/absent +# `tool_name`, a `tool_name` outside the write-class list, and a `tool_input` +# that is not a JSON object. Those cannot be distinguished from non-write +# traffic, and a gate protecting two files in one KB must not become a global +# Write/Edit outage. set -euo pipefail @@ -98,17 +116,110 @@ else echo "[factlog GATE] note: factlog config resolver unavailable; freshness gate falling back to \${FACTLOG_ROOT:-cwd} (KB_ROOT=$KB_ROOT)" >&2 fi -# Extract the tool target from the hook payload. -# Claude Code sends the tool input as JSON on stdin. -# The relevant field is "file_path" for Write and "file_path" for Edit. -target_path="$(printf '%s' "$payload" | "${PYTHON_RUNNER[@]}" -c \ - "import json,sys; d=json.load(sys.stdin); print(d.get('file_path','') or d.get('path',''))" \ - 2>/dev/null || true)" +# Extract the tool target from the hook payload (issue #323). +# +# Claude Code sends an ENVELOPE on stdin, not the bare tool input: +# {"session_id":..,"cwd":..,"hook_event_name":"PreToolUse","tool_name":"Write", +# "tool_input":{"file_path":..,"content":..},"tool_use_id":..} +# so the target path lives under `tool_input`, which the previous extractor +# never looked at — every real payload fell through to the fail-open branch. +# +# Key precedence: `tool_input` first, then the TOP LEVEL as a fallback. No real +# Claude Code payload puts `file_path` at the top level; that fallback exists to +# keep the flat fixture shape used by tests/test_gate_check.sh working. +# `notebook_path` is defensive only: hooks.json registers the matcher "Write|Edit", +# which Claude Code compares by exact tool name, so NotebookEdit (and MultiEdit) +# never reach this hook. It costs nothing and covers a user who widens the +# matcher in their own settings.json. +# +# The extractor pulls each field under its OWN try/except and always writes +# exactly three NUL-terminated fields, so a failure in one field cannot truncate +# the others. NUL is the separator because a path may legally contain a newline. +# (Fields are read straight off a pipe: bash command substitution silently drops +# NUL bytes, so `$(...)` cannot be used to capture this.) +GATE_EXTRACT_PY=" +import json, sys +PATH_KEYS = (\"file_path\", \"path\", \"notebook_path\") +try: + payload = json.load(sys.stdin) +except Exception: + payload = None +try: + name = payload.get(\"tool_name\") + tool_name = name if isinstance(name, str) else \"\" +except Exception: + tool_name = \"\" +try: + if not isinstance(payload, dict) or \"tool_input\" not in payload: + input_kind = \"absent\" + elif isinstance(payload[\"tool_input\"], dict): + input_kind = \"object\" + else: + input_kind = \"other\" +except Exception: + input_kind = \"absent\" +try: + target = \"\" + nested = payload.get(\"tool_input\") if isinstance(payload, dict) else None + for source in (nested, payload): + if not isinstance(source, dict): + continue + for key in PATH_KEYS: + value = source.get(key) + if isinstance(value, str) and value: + target = value + break + if target: + break +except Exception: + target = \"\" +sys.stdout.write(tool_name + \"\\0\" + target + \"\\0\" + input_kind + \"\\0\") +" + +tool_name="" +target_path="" +tool_input_kind="absent" +if ! { IFS= read -r -d '' tool_name \ + && IFS= read -r -d '' target_path \ + && IFS= read -r -d '' tool_input_kind; } \ + < <(printf '%s' "$payload" | "${PYTHON_RUNNER[@]}" -c "$GATE_EXTRACT_PY" 2>/dev/null); then + # The extractor produced no complete record (e.g. the interpreter died). Treat + # it as an unparseable payload: fail OPEN, same as before this change. + tool_name="" + target_path="" + tool_input_kind="absent" +fi + +# Write-class tool names, matched EXACTLY. A user may register this hook with a +# broader matcher in their own settings.json, so the deny branch below must key +# off the tool name rather than assume only Write/Edit arrive. Anything outside +# this list — including an absent tool_name, which is what the flat test +# fixtures send — is not a write we can reason about, and falls open. +_is_write_tool() { + case "$1" in + Write|Edit) return 0 ;; + *) return 1 ;; + esac +} -# If we could not extract a path, allow the tool to proceed (fail open). -# An empty/unparseable payload cannot target an engine input, so allowing here -# does not weaken the engine-input guard below. if [ -z "$target_path" ]; then + if [ "$tool_input_kind" = "object" ] && _is_write_tool "$tool_name"; then + # Narrow fail-closed: a write-class call carrying a structured tool_input + # from which no path key could be read. The payload schema drifted; we + # cannot tell whether it targets an engine input. + if [ "${FACTLOG_GATE_FAIL_OPEN:-}" = "1" ]; then + echo "[factlog GATE] note: could not read a target path from the $tool_name payload; FACTLOG_GATE_FAIL_OPEN=1 is set, so the write is allowed unchecked." >&2 + exit 0 + fi + echo "[factlog GATE] DENIED: could not read a target path from the $tool_name tool payload." >&2 + echo " The hook payload schema changed, so the freshness predicate cannot be evaluated" >&2 + echo " and this write cannot be shown to miss facts/accepted.dl or facts/query.dl." >&2 + echo " Re-run with FACTLOG_GATE_FAIL_OPEN=1 to bypass this specific check (the" >&2 + echo " freshness deny still applies), and please report the payload shape upstream." >&2 + exit 2 + fi + # Fail OPEN for everything else: unparseable payload, non-write tool, absent + # tool_name, or a tool_input that is not a JSON object. exit 0 fi diff --git a/tests/test_gate_check.sh b/tests/test_gate_check.sh index 454bd78c..ae0ceba5 100644 --- a/tests/test_gate_check.sh +++ b/tests/test_gate_check.sh @@ -42,6 +42,29 @@ run_case() { fi } +# --------------------------------------------------------------------------- +# Helper: run gate for a given KB root with a VERBATIM payload and expected exit. +# Used by the envelope cases (#323), where the payload shape is the thing under +# test and cannot be derived from a path alone. +# --------------------------------------------------------------------------- +run_payload_case() { + local desc="$1" + local kb_root="$2" + local payload="$3" + local expected_exit="$4" + + local actual_exit=0 + FACTLOG_ROOT="$kb_root" bash "$GATE" <<< "$payload" >/dev/null 2>&1 || actual_exit=$? + + if [ "$actual_exit" -eq "$expected_exit" ]; then + echo "PASS: $desc (exit $actual_exit)" + pass=$((pass + 1)) + else + echo "FAIL: $desc — expected exit $expected_exit, got $actual_exit" + fail=$((fail + 1)) + fi +} + # --------------------------------------------------------------------------- # Setup helpers # --------------------------------------------------------------------------- @@ -468,6 +491,230 @@ else fi rm -rf "$KB_OK" "$ok_err" +# =========================================================================== +# CASES 18-33: REAL HOOK ENVELOPE (#323) +# +# Claude Code does not send the bare tool input; it sends an envelope with the +# tool input nested under `tool_input`. CASES 1-17 above all use the FLAT +# fixture shape, which no production payload has, so before the #323 fix the +# gate returned exit 0 for every envelope payload — the freshness guard never +# fired in production and the harness never noticed. +# +# Each case below is labelled with its PRE-FIX result. "vacuous pre-fix" means +# the old gate passed the case only because it allowed everything; those cases +# pin behaviour but are NOT evidence of the defect. The cases that genuinely +# failed before the fix are marked "PRE-FIX FAIL". +# =========================================================================== + +kb_no_report() { + # Existing engine input, no report → the stale-guard must DENY. + local root="$1" + make_kb "$root" + touch_file "$root/facts/accepted.dl" +} + +kb_stale() { + # Report older than accepted.dl → DENY. + local root="$1" + make_kb "$root" + touch_file "$root/facts/logic_report.txt" + set_mtime_past "$root/facts/logic_report.txt" + touch_file "$root/facts/accepted.dl" +} + +kb_fresh() { + # Report newer than accepted.dl → ALLOW. + local root="$1" + make_kb "$root" + touch_file "$root/facts/accepted.dl" + set_mtime_past "$root/facts/accepted.dl" + touch_file "$root/facts/logic_report.txt" +} + +envelope() { + # The payload Claude Code actually sends for a Write/Edit PreToolUse hook. + local tool_name="$1" + local target="$2" + printf '{"session_id":"s","cwd":"/tmp","hook_event_name":"PreToolUse","tool_name":"%s","tool_input":{"file_path":"%s","content":"x"},"tool_use_id":"u"}' \ + "$tool_name" "$target" +} + +# --------------------------------------------------------------------------- +# CASE 18: envelope, existing engine input, report absent — DENY. +# PRE-FIX FAIL (old gate returned 0). This is the issue's core reproduction. +# --------------------------------------------------------------------------- +KB_ENV1="$(mktemp -d)" +kb_no_report "$KB_ENV1" +run_payload_case "envelope: engine input, report absent — deny" \ + "$KB_ENV1" "$(envelope Write "$KB_ENV1/facts/accepted.dl")" 2 +rm -rf "$KB_ENV1" + +# --------------------------------------------------------------------------- +# CASE 19: envelope, report stale — DENY. +# PRE-FIX FAIL (old gate returned 0). +# --------------------------------------------------------------------------- +KB_ENV2="$(mktemp -d)" +kb_stale "$KB_ENV2" +run_payload_case "envelope: engine input, report stale — deny" \ + "$KB_ENV2" "$(envelope Edit "$KB_ENV2/facts/accepted.dl")" 2 +rm -rf "$KB_ENV2" + +# --------------------------------------------------------------------------- +# CASE 20: envelope, report fresh — ALLOW (acceptance criterion; vacuous pre-fix). +# --------------------------------------------------------------------------- +KB_ENV3="$(mktemp -d)" +kb_fresh "$KB_ENV3" +run_payload_case "envelope: engine input, report fresh — allow" \ + "$KB_ENV3" "$(envelope Write "$KB_ENV3/facts/accepted.dl")" 0 +rm -rf "$KB_ENV3" + +# --------------------------------------------------------------------------- +# CASE 21: envelope bootstrap — fresh KB creating query.dl — ALLOW. +# Acceptance criterion; vacuous pre-fix. Guards against a fix that denies the +# first write in a fresh KB and deadlocks the question→query-draft flow. +# --------------------------------------------------------------------------- +KB_ENV4="$(mktemp -d)" +make_kb "$KB_ENV4" +run_payload_case "envelope: bootstrap fresh KB creating query.dl — allow" \ + "$KB_ENV4" "$(envelope Write "$KB_ENV4/facts/query.dl")" 0 +rm -rf "$KB_ENV4" + +# --------------------------------------------------------------------------- +# CASE 22: envelope, target is not an engine input — ALLOW (vacuous pre-fix). +# --------------------------------------------------------------------------- +KB_ENV5="$(mktemp -d)" +kb_stale "$KB_ENV5" +run_payload_case "envelope: non-engine-input target in a stale KB — allow" \ + "$KB_ENV5" "$(envelope Write "$KB_ENV5/notes.md")" 0 +rm -rf "$KB_ENV5" + +# --------------------------------------------------------------------------- +# CASE 23: NARROW FAIL-CLOSED — write-class tool, `tool_input` IS an object, but +# it carries no known path key — DENY. +# PRE-FIX FAIL (old gate returned 0). +# +# This is the schema-drift branch: the payload is a write we cannot evaluate, so +# we cannot show it misses the engine inputs. +# --------------------------------------------------------------------------- +KB_ENV6="$(mktemp -d)" +kb_stale "$KB_ENV6" +run_payload_case "envelope: Write with no path key in tool_input — fail-closed deny" \ + "$KB_ENV6" '{"tool_name":"Write","tool_input":{"content":"x"}}' 2 + +# --------------------------------------------------------------------------- +# CASE 24: the fail-closed branch requires a write-class `tool_name`. With NO +# tool_name (the shape CASES 1-17 send) the gate must ALLOW — otherwise the +# entire flat harness flips to DENY. Vacuous pre-fix; kills a mutant that denies +# on every empty path. +# --------------------------------------------------------------------------- +run_payload_case "envelope: no tool_name, no path key — allow (not a known write)" \ + "$KB_ENV6" '{"tool_input":{"content":"x"}}' 0 + +# --------------------------------------------------------------------------- +# CASE 25: tool_name outside the write-class list — ALLOW (vacuous pre-fix). +# Pins the exact-match small list: a user who widens the matcher in their own +# settings.json must not have unrelated tools denied. +# --------------------------------------------------------------------------- +run_payload_case "envelope: non-write tool_name, no path key — allow" \ + "$KB_ENV6" '{"tool_name":"Read","tool_input":{"content":"x"}}' 0 +rm -rf "$KB_ENV6" + +# --------------------------------------------------------------------------- +# CASE 26: KEY PRECEDENCE — `tool_input.file_path` wins over a top-level +# `file_path`. Here the nested path IS the engine input and the top-level one is +# not, so the gate must DENY. PRE-FIX FAIL (old gate read the top level → 0). +# --------------------------------------------------------------------------- +KB_ENV7="$(mktemp -d)" +kb_stale "$KB_ENV7" +run_payload_case "envelope: tool_input.file_path wins over top-level — deny" \ + "$KB_ENV7" \ + "$(printf '{"tool_name":"Write","file_path":"%s","tool_input":{"file_path":"%s"}}' \ + "$KB_ENV7/notes.md" "$KB_ENV7/facts/accepted.dl")" 2 + +# --------------------------------------------------------------------------- +# CASE 27: the same precedence in the opposite direction — the nested path is +# NOT an engine input while the top-level one is, so the gate must ALLOW. +# PRE-FIX FAIL (old gate read the top level → 2). Together with CASE 26 this +# pins the precedence in both directions; either case alone is passed by an +# implementation that simply merges the two dicts. +# --------------------------------------------------------------------------- +run_payload_case "envelope: top-level file_path ignored when tool_input has one — allow" \ + "$KB_ENV7" \ + "$(printf '{"tool_name":"Write","file_path":"%s","tool_input":{"file_path":"%s"}}' \ + "$KB_ENV7/facts/accepted.dl" "$KB_ENV7/notes.md")" 0 + +# --------------------------------------------------------------------------- +# CASE 28: top-level FALLBACK is still consulted when `tool_input` carries no +# path key. No production payload has a top-level file_path; this keeps the flat +# fixture shape of CASES 1-17 working. Vacuous pre-fix. +# --------------------------------------------------------------------------- +run_payload_case "envelope: falls back to top-level file_path — deny" \ + "$KB_ENV7" \ + "$(printf '{"file_path":"%s","tool_input":{"content":"x"}}' "$KB_ENV7/facts/accepted.dl")" 2 + +# --------------------------------------------------------------------------- +# CASES 29-31: `tool_input` present but NOT an object (null / string / array). +# That is not the narrow fail-closed condition, so the gate must ALLOW rather +# than deny — and, more importantly, the extractor must not die partway through +# and leave the shell reading a truncated record. Vacuous pre-fix; these are the +# cases that kill an extractor which calls .get() on a non-dict. +# +# CASE 31 deliberately hides an engine-input path inside an ARRAY: a path is +# only honoured from an object, so this must still ALLOW. +# --------------------------------------------------------------------------- +run_payload_case "envelope: tool_input is null — allow (not the fail-closed shape)" \ + "$KB_ENV7" '{"tool_name":"Write","tool_input":null}' 0 +run_payload_case "envelope: tool_input is a string — allow (not the fail-closed shape)" \ + "$KB_ENV7" '{"tool_name":"Write","tool_input":"oops"}' 0 +run_payload_case "envelope: tool_input is an array — allow (not the fail-closed shape)" \ + "$KB_ENV7" \ + "$(printf '{"tool_name":"Write","tool_input":[{"file_path":"%s"}]}' "$KB_ENV7/facts/accepted.dl")" 0 + +# --------------------------------------------------------------------------- +# CASE 32: MUTATION PIN (vacuous pre-fix) — the engine-input path appears only +# inside `content`, while the actual target is an unrelated file → ALLOW. +# +# A grep-the-whole-payload "fix" (the shape hooks/gate_reminder.sh:17 uses for +# its non-blocking nudge) would DENY here and make every write that merely +# mentions facts/accepted.dl unblockable without a logic check. +# --------------------------------------------------------------------------- +run_payload_case "envelope: engine-input path only inside content — allow (no payload grep)" \ + "$KB_ENV7" \ + "$(printf '{"tool_name":"Write","tool_input":{"file_path":"%s","content":"see %s"}}' \ + "$KB_ENV7/notes.md" "$KB_ENV7/facts/accepted.dl")" 0 + +# --------------------------------------------------------------------------- +# CASE 33: FACTLOG_GATE_FAIL_OPEN=1 escape hatch. +# +# (a) It releases the narrow schema-drift deny of CASE 23 — without an escape +# hatch, a payload-schema change turns this gate into a global Write/Edit +# outage, and the deny stderr is fed back to the model as a retry loop. +# (b) It must NOT release the freshness deny, which is the gate's whole purpose. +# Vacuous pre-fix for (a); PRE-FIX FAIL for (b) (old gate returned 0). +# --------------------------------------------------------------------------- +hatch_exit=0 +FACTLOG_GATE_FAIL_OPEN=1 FACTLOG_ROOT="$KB_ENV7" bash "$GATE" \ + <<< '{"tool_name":"Write","tool_input":{"content":"x"}}' >/dev/null 2>&1 || hatch_exit=$? +if [ "$hatch_exit" -eq 0 ]; then + echo "PASS: FACTLOG_GATE_FAIL_OPEN=1 releases the schema-drift deny (exit $hatch_exit)" + pass=$((pass + 1)) +else + echo "FAIL: FACTLOG_GATE_FAIL_OPEN=1 — expected allow (exit 0), got $hatch_exit" + fail=$((fail + 1)) +fi + +hatch_fresh_exit=0 +FACTLOG_GATE_FAIL_OPEN=1 FACTLOG_ROOT="$KB_ENV7" bash "$GATE" \ + <<< "$(envelope Write "$KB_ENV7/facts/accepted.dl")" >/dev/null 2>&1 || hatch_fresh_exit=$? +if [ "$hatch_fresh_exit" -eq 2 ]; then + echo "PASS: FACTLOG_GATE_FAIL_OPEN=1 does NOT release the freshness deny (exit $hatch_fresh_exit)" + pass=$((pass + 1)) +else + echo "FAIL: FACTLOG_GATE_FAIL_OPEN=1 — freshness deny must still fire (exit 2), got $hatch_fresh_exit" + fail=$((fail + 1)) +fi +rm -rf "$KB_ENV7" + # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- From 5927df5b59a2d2b70189367a8aaaeda0061b8bfc Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 11:41:40 +0900 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20=EC=83=88=20=EA=B1=B0=EB=B6=80=20=EC=82=AC=EC=9C=A0?= =?UTF-8?q?=EC=99=80=20=ED=83=88=EC=B6=9C=EA=B5=AC=EB=A5=BC=20=EC=A0=81?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreToolUse 게이트는 이제 신선도 외에 한 가지 사유로 더 거부한다. Write/Edit 호출인데 페이로드에서 대상 경로를 읽을 수 없으면, 그 쓰기가 엔진 입력을 겨냥하지 않는다고 보일 수 없으므로 통과시키지 않는다. 사용자에게 보이는 새 거부이므로 결정론 가이드 ko/en 쌍에 사유와 FACTLOG_GATE_FAIL_OPEN=1 탈출구를 함께 적는다. 이 변수가 신선도 거부까지 풀지는 않는다는 점도 명시한다. 업스트림 #323 --- docs/guide/determinism.en.md | 9 +++++++++ docs/guide/determinism.md | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/guide/determinism.en.md b/docs/guide/determinism.en.md index e900b9d8..7d63db19 100644 --- a/docs/guide/determinism.en.md +++ b/docs/guide/determinism.en.md @@ -15,6 +15,15 @@ factlog enforces freshness through two distinct mechanisms: These two levels are complementary: the hook closes the deterministic gap; the SKILL discipline covers the narration layer where engineering enforcement is not possible. +The hook denies for **one more reason** besides staleness. It reads the target +path out of the tool payload Claude Code sends; if the call is a `Write`/`Edit` +but the payload shape has changed such that no path can be read — so the write +cannot be shown *not* to target an engine input — the hook denies rather than +letting it through. Only in that situation can you skip the check with +`FACTLOG_GATE_FAIL_OPEN=1` (the deny message names it); that variable does +**not** release the staleness deny. If you hit it, please report the payload +shape upstream. + ### Scale & performance **You don't need to empty the KB for performance.** The logic-check cost depends diff --git a/docs/guide/determinism.md b/docs/guide/determinism.md index 5b50cba4..b1e9962a 100644 --- a/docs/guide/determinism.md +++ b/docs/guide/determinism.md @@ -20,6 +20,14 @@ factlog는 두 가지 서로 다른 메커니즘으로 신선도(freshness)를 이 두 단계는 상호 보완적입니다. 훅은 결정론적 빈틈을 메우고, SKILL 규율은 엔지니어링적 강제가 불가능한 서술(narration) 계층을 담당합니다. +훅은 신선도 외에 **한 가지 사유로 더** 거부합니다. 훅은 Claude Code 가 보내는 +도구 페이로드에서 대상 경로를 읽어 판정하는데, `Write`/`Edit` 호출이면서 페이로드 +구조가 바뀌어 그 경로를 전혀 읽을 수 없으면 — 즉 그 쓰기가 엔진 입력을 겨냥하지 +*않는다*는 것을 보일 수 없으면 — 통과시키지 않고 거부합니다. 이 상황에서만 +`FACTLOG_GATE_FAIL_OPEN=1` 로 해당 검사를 건너뛸 수 있으며(거부 메시지에도 이 +방법이 안내됩니다), 이 변수로 **신선도 거부가 풀리지는 않습니다.** 페이로드를 읽지 +못했다면 그 형태를 업스트림에 알려 주세요. + ### 규모와 성능 **성능 때문에 KB를 비울 필요는 없습니다.** 로직 체크 비용은 총 사실 수보다 From 6a6e895857701728b3511aeca9c8c11abeddd1b5 Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 12:00:50 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=20=EC=95=88=EB=82=B4=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EA=B0=80=EB=8A=A5=ED=95=9C=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=EC=97=90=EA=B2=8C=20=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스키마 드리프트 거부의 stderr 는 모델에게 되돌아가지만, "Re-run with FACTLOG_GATE_FAIL_OPEN=1" 은 모델이 실행할 수 없는 안내였다. PreToolUse 훅은 Claude Code 프로세스의 환경을 물려받으므로, 모델이 Bash 로 그 변수를 붙여 무엇을 실행하든 자기 도구 호출에는 반영되지 않는다. 탈출구를 넣은 명분이 안내대로 해도 유지되던 셈이다. 문장을 사람 대상으로 바꾸고 설정 위치를 적는다 — settings.json 의 env 블록이거나 Claude Code 실행 전 export, 그리고 새 세션. 서술 정정도 함께 한다. 동작 변경은 없다. - fail-closed 를 "정확히 둘, 둘 다 탈출구 있음" 이라고 단언했으나 _mtime 실패가 탈출구 없는 세 번째 갈래다. 명령 치환 안의 exit 2 가 set -e 로 스크립트를 끝낸다. 셋으로 열거하고 탈출구 유무를 구분한다. - fail-open 열거에 "추출기가 불완전한 레코드를 냈다" 가 빠져 있었다. 이건 좁힌 fail-closed 의 근거와 반대 방향으로 떨어지는데, 의도한 것이다 — 레코드가 없으면 그 호출이 쓰기인지조차 알 수 없다. 열거에 넣고 왜 반대로 가는지 적는다. - 프로세스 치환을 파싱하지 못하는 셸에서는 fail-open 이 아니라 구문 오류이고, bash 는 구문 오류에 exit 2 로 죽어 PreToolUse 에서 거부가 된다. 도달 가능성이 사실상 없어 코드는 두되 방향을 틀리게 적어 두지 않는다. - 빈 문자열 file_path 도 이 갈래로 떨어져 리포트가 신선해도 거부된다는 점, JSON 문자열 안의 NUL 이 레코드 분할을 밀 수 있다는 점을 적는다. - 세 필드를 쓰는 근거를 다시 쓴다. 두 필드로 불가능해서가 아니라, 도구명을 날것으로 들고 있어야 write 판정이 hooks.json matcher 옆의 읽을 수 있는 case 로 남고 거부 메시지가 도구명을 부를 수 있기 때문이다. 업스트림 #323 --- hooks/gate_check.sh | 89 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 20 deletions(-) diff --git a/hooks/gate_check.sh b/hooks/gate_check.sh index 95c1eb61..ed47c624 100755 --- a/hooks/gate_check.sh +++ b/hooks/gate_check.sh @@ -52,7 +52,7 @@ # That degrade is made OBSERVABLE: when Python is available but the resolver # returns empty (package import failure), a one-line stderr note is emitted so # the silent permissive fallback is visible to an operator (see below). -# There are exactly TWO fail-closed branches, both with an env escape hatch: +# THREE branches DENY without evaluating the freshness predicate: # 1. The python-availability check below DENYs when no usable Python 3.11+ is # present, since the predicate cannot then be evaluated. Escape hatch: # FACTLOG_PYTHON (point it at a usable interpreter). @@ -62,14 +62,27 @@ # either `tool_input` or the top level. That combination means the payload # schema drifted out from under the extractor while a write was in flight, # so the predicate cannot be evaluated for a write that may well target an -# engine input. Escape hatch: FACTLOG_GATE_FAIL_OPEN=1 (named in the deny -# message, since a PreToolUse exit 2 stderr is fed back to the model and -# without a stated recovery it becomes a retry loop). -# Everything else fails OPEN (exit 0): unparseable payloads, a missing/absent -# `tool_name`, a `tool_name` outside the write-class list, and a `tool_input` -# that is not a JSON object. Those cannot be distinguished from non-write -# traffic, and a gate protecting two files in one KB must not become a global -# Write/Edit outage. +# engine input. Escape hatch: FACTLOG_GATE_FAIL_OPEN=1, which only a human +# can set (see the deny message below). +# 3. _mtime() DENYs when `stat` cannot report a file's mtime. That branch has +# NO escape hatch; it is only reachable for a file this script has already +# seen pass `-f`, i.e. a race or an unreadable filesystem. +# Only branches 1 and 2 carry an escape hatch, and only branches 1 and 2 are +# about the payload/interpreter layer. +# +# Everything else fails OPEN (exit 0): +# - an unparseable payload; +# - an absent `tool_name`; +# - a `tool_name` outside the write-class list; +# - a `tool_input` that is not a JSON object; +# - an INCOMPLETE record from the extractor — the interpreter died, or wrote +# something that is not three NUL-terminated fields. Note this lands the +# OPPOSITE way from branch 2's reasoning, deliberately: with no record at +# all we cannot tell that the call is even a write, so denying would block +# every tool call in the session on evidence we do not have. Branch 2 denies +# because we positively know it IS a write we cannot evaluate. +# A gate protecting two files in one KB must not become a global Write/Edit +# outage, so anything short of positive knowledge falls open. set -euo pipefail @@ -134,9 +147,26 @@ fi # # The extractor pulls each field under its OWN try/except and always writes # exactly three NUL-terminated fields, so a failure in one field cannot truncate -# the others. NUL is the separator because a path may legally contain a newline. -# (Fields are read straight off a pipe: bash command substitution silently drops -# NUL bytes, so `$(...)` cannot be used to capture this.) +# the others. NUL is the separator because a path may legally contain a newline, +# and a newline separator misreads such a path (or, with a leading newline, +# denies a perfectly legal write). A JSON string CAN itself contain a NUL, which +# shifts the remaining fields; that is a wash here — a truncated engine-input +# path still matches the engine input (deny), and a NUL-prefixed one is a path +# the OS cannot write to anyway. +# +# Three fields, not two: `tool_name` is carried raw so the write-class decision +# stays a readable `case` in shell right next to hooks.json's matcher, and so +# the deny message can name the offending tool. Collapsing the tool name and the +# tool_input shape into one field is possible, but it moves that decision into +# the embedded Python where it is harder to audit. +# +# Fields are read straight off a pipe: bash command substitution silently drops +# NUL bytes, so `$(...)` cannot capture this. A shell that cannot parse process +# substitution would raise a SYNTAX error, and bash exits 2 on one — which +# PreToolUse reads as DENY, not as a fail-open. That is unreachable in practice +# (bash falls back to FIFOs where /dev/fd is missing, and macOS, Linux and Git +# Bash all support it), so the code is left as is; it is noted only so the +# failure direction is not misdescribed. GATE_EXTRACT_PY=" import json, sys PATH_KEYS = (\"file_path\", \"path\", \"notebook_path\") @@ -183,8 +213,10 @@ if ! { IFS= read -r -d '' tool_name \ && IFS= read -r -d '' target_path \ && IFS= read -r -d '' tool_input_kind; } \ < <(printf '%s' "$payload" | "${PYTHON_RUNNER[@]}" -c "$GATE_EXTRACT_PY" 2>/dev/null); then - # The extractor produced no complete record (e.g. the interpreter died). Treat - # it as an unparseable payload: fail OPEN, same as before this change. + # The extractor produced no complete record (the interpreter died, or wrote + # something that is not three NUL-terminated fields). Treat it as an + # unparseable payload: fail OPEN, the pre-#323 behaviour. See the header for + # why this lands opposite to the narrow fail-closed branch below. tool_name="" target_path="" tool_input_kind="absent" @@ -205,8 +237,16 @@ _is_write_tool() { if [ -z "$target_path" ]; then if [ "$tool_input_kind" = "object" ] && _is_write_tool "$tool_name"; then # Narrow fail-closed: a write-class call carrying a structured tool_input - # from which no path key could be read. The payload schema drifted; we - # cannot tell whether it targets an engine input. + # from which no path key could be read. Usually that means the payload + # schema drifted; a present-but-empty file_path lands here too, and is + # denied regardless of report freshness because this branch runs before the + # engine-input match. Either way we cannot tell whether it targets an + # engine input. + # + # The escape hatch is read from THIS process's environment, which a hook + # inherits from the Claude Code process. A model cannot set it for its own + # tool calls from inside the session, so the deny message is addressed to a + # human operator and says where to set it. if [ "${FACTLOG_GATE_FAIL_OPEN:-}" = "1" ]; then echo "[factlog GATE] note: could not read a target path from the $tool_name payload; FACTLOG_GATE_FAIL_OPEN=1 is set, so the write is allowed unchecked." >&2 exit 0 @@ -214,12 +254,16 @@ if [ -z "$target_path" ]; then echo "[factlog GATE] DENIED: could not read a target path from the $tool_name tool payload." >&2 echo " The hook payload schema changed, so the freshness predicate cannot be evaluated" >&2 echo " and this write cannot be shown to miss facts/accepted.dl or facts/query.dl." >&2 - echo " Re-run with FACTLOG_GATE_FAIL_OPEN=1 to bypass this specific check (the" >&2 - echo " freshness deny still applies), and please report the payload shape upstream." >&2 + echo " This cannot be worked around from inside the session. Ask the operator to set" >&2 + echo " FACTLOG_GATE_FAIL_OPEN=1 in the Claude Code environment (the \"env\" block of" >&2 + echo " settings.json, or export it before launching Claude Code) and start a new" >&2 + echo " session. That bypasses only this check — the freshness deny still applies." >&2 + echo " Please also report the payload shape upstream." >&2 exit 2 fi - # Fail OPEN for everything else: unparseable payload, non-write tool, absent - # tool_name, or a tool_input that is not a JSON object. + # Fail OPEN for everything else: unparseable payload, an incomplete extractor + # record, a non-write tool, an absent tool_name, or a tool_input that is not a + # JSON object. exit 0 fi @@ -274,6 +318,11 @@ if [ ! -f "$report" ]; then exit 2 fi +# Fail-closed branch 3 (see header): if stat cannot report an mtime we cannot +# compare freshness, so we deny. The `exit 2` inside this command substitution +# ends the whole script under `set -e`. There is no escape hatch here — the file +# has already passed `-f` a line earlier, so reaching this needs a race or an +# unreadable filesystem, not a schema change someone has to work around. _mtime() { local value if value="$(stat -c %Y "$1" 2>/dev/null)" || value="$(stat -f %m "$1" 2>/dev/null)"; then From 8af02e7eda63d367d7b91fa101c1c6071e97b3bd Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 12:01:05 +0900 Subject: [PATCH 4/7] =?UTF-8?q?docs:=20=EA=B8=B0=EC=A1=B4=20KB=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=97=90=EA=B2=8C=20=EC=B2=AB=20=EB=B0=9C?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20=EC=98=88=EA=B3=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 수정 전까지 훅은 실제 페이로드를 읽지 못해 신선도 거부를 한 번도 내지 않았다. 따라서 facts/accepted.dl 이나 facts/query.dl 이 이미 있고 리포트가 없거나 낡은 기존 사용자는, 이번 수정 이후 모델의 Write/Edit 에서 처음으로 하드 거부를 받는다. 앞선 문서 문단은 새 스키마 드리프트 거부만 설명했고 이 전환을 다루지 않았다. 레포에 CHANGELOG 가 없으므로 결정론 가이드에 적는다. /factlog check 를 먼저 돌리면 편집이 풀린다. FACTLOG_GATE_FAIL_OPEN 도 설정 위치가 빠져 있었다. 훅은 Claude Code 프로세스의 환경을 물려받으므로 세션 안에서 모델이 스스로 켤 수 없다 — 사람이 settings.json 의 env 블록에 넣거나 실행 전 export 한 뒤 새 세션을 시작해야 한다. ko/en 쌍에 동일하게 반영한다. 업스트림 #323 --- docs/guide/determinism.en.md | 15 ++++++++++++--- docs/guide/determinism.md | 16 +++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/guide/determinism.en.md b/docs/guide/determinism.en.md index 7d63db19..5d15d9e7 100644 --- a/docs/guide/determinism.en.md +++ b/docs/guide/determinism.en.md @@ -15,14 +15,23 @@ factlog enforces freshness through two distinct mechanisms: These two levels are complementary: the hook closes the deterministic gap; the SKILL discipline covers the narration layer where engineering enforcement is not possible. +> **An existing KB needs one `/factlog check` first.** In earlier versions the +> hook could not read Claude Code's real payload, so this deny never fired. From +> this fix on, a KB that already has `facts/accepted.dl` or `facts/query.dl` +> while `facts/logic_report.txt` is missing or older must be refreshed with +> `/factlog check` before its engine inputs can be edited. + The hook denies for **one more reason** besides staleness. It reads the target path out of the tool payload Claude Code sends; if the call is a `Write`/`Edit` but the payload shape has changed such that no path can be read — so the write cannot be shown *not* to target an engine input — the hook denies rather than letting it through. Only in that situation can you skip the check with -`FACTLOG_GATE_FAIL_OPEN=1` (the deny message names it); that variable does -**not** release the staleness deny. If you hit it, please report the payload -shape upstream. +`FACTLOG_GATE_FAIL_OPEN=1`, and that variable does **not** release the staleness +deny. It is not something the model can do from inside the session: a hook +inherits the Claude Code process environment, so **a human** has to set it there +— in the `env` block of `settings.json`, or exported before launching Claude +Code — and then start a new session. Please also report the payload shape +upstream. ### Scale & performance diff --git a/docs/guide/determinism.md b/docs/guide/determinism.md index b1e9962a..bd005ab4 100644 --- a/docs/guide/determinism.md +++ b/docs/guide/determinism.md @@ -20,13 +20,23 @@ factlog는 두 가지 서로 다른 메커니즘으로 신선도(freshness)를 이 두 단계는 상호 보완적입니다. 훅은 결정론적 빈틈을 메우고, SKILL 규율은 엔지니어링적 강제가 불가능한 서술(narration) 계층을 담당합니다. +> **기존 KB 를 쓰고 있었다면 한 번은 `/factlog check` 가 필요합니다.** 이전 +> 버전에서는 훅이 Claude Code 의 실제 페이로드를 읽지 못해 이 거부가 한 번도 +> 발동하지 않았습니다. 이번 수정 이후로는, `facts/accepted.dl` 이나 +> `facts/query.dl` 이 이미 있는데 `facts/logic_report.txt` 가 없거나 낡은 KB +> 에서는 `/factlog check` 로 리포트를 먼저 갱신해야 엔진 입력을 편집할 수 +> 있습니다. + 훅은 신선도 외에 **한 가지 사유로 더** 거부합니다. 훅은 Claude Code 가 보내는 도구 페이로드에서 대상 경로를 읽어 판정하는데, `Write`/`Edit` 호출이면서 페이로드 구조가 바뀌어 그 경로를 전혀 읽을 수 없으면 — 즉 그 쓰기가 엔진 입력을 겨냥하지 *않는다*는 것을 보일 수 없으면 — 통과시키지 않고 거부합니다. 이 상황에서만 -`FACTLOG_GATE_FAIL_OPEN=1` 로 해당 검사를 건너뛸 수 있으며(거부 메시지에도 이 -방법이 안내됩니다), 이 변수로 **신선도 거부가 풀리지는 않습니다.** 페이로드를 읽지 -못했다면 그 형태를 업스트림에 알려 주세요. +`FACTLOG_GATE_FAIL_OPEN=1` 로 해당 검사를 건너뛸 수 있고, 이 변수로 **신선도 +거부가 풀리지는 않습니다.** 이건 세션 안에서 모델이 스스로 할 수 있는 일이 아닙니다 +— 훅은 Claude Code 프로세스의 환경을 물려받으므로, **사람이** Claude Code 환경에 +설정해야 합니다. `settings.json` 의 `env` 블록에 넣거나 Claude Code 를 띄우기 전에 +export 한 뒤, 새 세션을 시작하세요. 그리고 읽지 못한 페이로드 형태를 업스트림에 +알려 주세요. ### 규모와 성능 From c96659f7d9f683e6dbcf44d7aa6a8c08940532c8 Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 12:18:44 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=ED=83=88=EC=B6=9C=EA=B5=AC=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=9C=20=EB=B2=94=EC=9C=84=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FACTLOG_GATE_FAIL_OPEN 은 실제보다 넓은 약속을 하는 이름이었다. 이 변수가 푸는 것은 "판정 불가 페이로드" 한 갈래뿐이고 신선도 거부와 python 부재 거부는 그대로 두는데, 이름은 게이트 전체를 여는 스위치처럼 읽힌다. 다음 사람이 이름값을 하도록 범위를 넓히고 싶어질 이름이다. FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD 로 바꾼다. 면제 갈래가 이름에 박혀 있어 확장 유혹이 원천 차단된다. env var 이름은 공개 계약이라 릴리스 뒤에 바꾸면 breaking change 이므로 지금 바꾼다 — 옛 이름은 어디에도 남기지 않는다. 코드·거부 메시지·주석·하니스 케이스와 결정론 가이드 ko/en 을 일괄로 바꿨다. 동작은 그대로다. 업스트림 #323 --- docs/guide/determinism.en.md | 5 +++-- docs/guide/determinism.md | 5 +++-- hooks/gate_check.sh | 8 ++++---- tests/test_gate_check.sh | 14 +++++++------- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/guide/determinism.en.md b/docs/guide/determinism.en.md index 5d15d9e7..8ebd31be 100644 --- a/docs/guide/determinism.en.md +++ b/docs/guide/determinism.en.md @@ -26,8 +26,9 @@ path out of the tool payload Claude Code sends; if the call is a `Write`/`Edit` but the payload shape has changed such that no path can be read — so the write cannot be shown *not* to target an engine input — the hook denies rather than letting it through. Only in that situation can you skip the check with -`FACTLOG_GATE_FAIL_OPEN=1`, and that variable does **not** release the staleness -deny. It is not something the model can do from inside the session: a hook +`FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1`. As the name says, it exempts the +unreadable-payload branch and nothing else — it does **not** release the +staleness deny. It is not something the model can do from inside the session: a hook inherits the Claude Code process environment, so **a human** has to set it there — in the `env` block of `settings.json`, or exported before launching Claude Code — and then start a new session. Please also report the payload shape diff --git a/docs/guide/determinism.md b/docs/guide/determinism.md index bd005ab4..b9ad75a5 100644 --- a/docs/guide/determinism.md +++ b/docs/guide/determinism.md @@ -31,8 +31,9 @@ factlog는 두 가지 서로 다른 메커니즘으로 신선도(freshness)를 도구 페이로드에서 대상 경로를 읽어 판정하는데, `Write`/`Edit` 호출이면서 페이로드 구조가 바뀌어 그 경로를 전혀 읽을 수 없으면 — 즉 그 쓰기가 엔진 입력을 겨냥하지 *않는다*는 것을 보일 수 없으면 — 통과시키지 않고 거부합니다. 이 상황에서만 -`FACTLOG_GATE_FAIL_OPEN=1` 로 해당 검사를 건너뛸 수 있고, 이 변수로 **신선도 -거부가 풀리지는 않습니다.** 이건 세션 안에서 모델이 스스로 할 수 있는 일이 아닙니다 +`FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1` 로 해당 검사를 건너뛸 수 있습니다. 이름 +그대로 **읽을 수 없는 페이로드** 한 갈래만 면제하며, 이 변수로 **신선도 거부는 +풀리지 않습니다.** 이건 세션 안에서 모델이 스스로 할 수 있는 일이 아닙니다 — 훅은 Claude Code 프로세스의 환경을 물려받으므로, **사람이** Claude Code 환경에 설정해야 합니다. `settings.json` 의 `env` 블록에 넣거나 Claude Code 를 띄우기 전에 export 한 뒤, 새 세션을 시작하세요. 그리고 읽지 못한 페이로드 형태를 업스트림에 diff --git a/hooks/gate_check.sh b/hooks/gate_check.sh index ed47c624..a5952620 100755 --- a/hooks/gate_check.sh +++ b/hooks/gate_check.sh @@ -62,7 +62,7 @@ # either `tool_input` or the top level. That combination means the payload # schema drifted out from under the extractor while a write was in flight, # so the predicate cannot be evaluated for a write that may well target an -# engine input. Escape hatch: FACTLOG_GATE_FAIL_OPEN=1, which only a human +# engine input. Escape hatch: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1, which only a human # can set (see the deny message below). # 3. _mtime() DENYs when `stat` cannot report a file's mtime. That branch has # NO escape hatch; it is only reachable for a file this script has already @@ -247,15 +247,15 @@ if [ -z "$target_path" ]; then # inherits from the Claude Code process. A model cannot set it for its own # tool calls from inside the session, so the deny message is addressed to a # human operator and says where to set it. - if [ "${FACTLOG_GATE_FAIL_OPEN:-}" = "1" ]; then - echo "[factlog GATE] note: could not read a target path from the $tool_name payload; FACTLOG_GATE_FAIL_OPEN=1 is set, so the write is allowed unchecked." >&2 + if [ "${FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD:-}" = "1" ]; then + echo "[factlog GATE] note: could not read a target path from the $tool_name payload; FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 is set, so the write is allowed unchecked." >&2 exit 0 fi echo "[factlog GATE] DENIED: could not read a target path from the $tool_name tool payload." >&2 echo " The hook payload schema changed, so the freshness predicate cannot be evaluated" >&2 echo " and this write cannot be shown to miss facts/accepted.dl or facts/query.dl." >&2 echo " This cannot be worked around from inside the session. Ask the operator to set" >&2 - echo " FACTLOG_GATE_FAIL_OPEN=1 in the Claude Code environment (the \"env\" block of" >&2 + echo " FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 in the Claude Code environment (the \"env\" block of" >&2 echo " settings.json, or export it before launching Claude Code) and start a new" >&2 echo " session. That bypasses only this check — the freshness deny still applies." >&2 echo " Please also report the payload shape upstream." >&2 diff --git a/tests/test_gate_check.sh b/tests/test_gate_check.sh index ae0ceba5..95361046 100644 --- a/tests/test_gate_check.sh +++ b/tests/test_gate_check.sh @@ -684,7 +684,7 @@ run_payload_case "envelope: engine-input path only inside content — allow (no "$KB_ENV7/notes.md" "$KB_ENV7/facts/accepted.dl")" 0 # --------------------------------------------------------------------------- -# CASE 33: FACTLOG_GATE_FAIL_OPEN=1 escape hatch. +# CASE 33: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 escape hatch. # # (a) It releases the narrow schema-drift deny of CASE 23 — without an escape # hatch, a payload-schema change turns this gate into a global Write/Edit @@ -693,24 +693,24 @@ run_payload_case "envelope: engine-input path only inside content — allow (no # Vacuous pre-fix for (a); PRE-FIX FAIL for (b) (old gate returned 0). # --------------------------------------------------------------------------- hatch_exit=0 -FACTLOG_GATE_FAIL_OPEN=1 FACTLOG_ROOT="$KB_ENV7" bash "$GATE" \ +FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 FACTLOG_ROOT="$KB_ENV7" bash "$GATE" \ <<< '{"tool_name":"Write","tool_input":{"content":"x"}}' >/dev/null 2>&1 || hatch_exit=$? if [ "$hatch_exit" -eq 0 ]; then - echo "PASS: FACTLOG_GATE_FAIL_OPEN=1 releases the schema-drift deny (exit $hatch_exit)" + echo "PASS: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 releases the schema-drift deny (exit $hatch_exit)" pass=$((pass + 1)) else - echo "FAIL: FACTLOG_GATE_FAIL_OPEN=1 — expected allow (exit 0), got $hatch_exit" + echo "FAIL: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 — expected allow (exit 0), got $hatch_exit" fail=$((fail + 1)) fi hatch_fresh_exit=0 -FACTLOG_GATE_FAIL_OPEN=1 FACTLOG_ROOT="$KB_ENV7" bash "$GATE" \ +FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 FACTLOG_ROOT="$KB_ENV7" bash "$GATE" \ <<< "$(envelope Write "$KB_ENV7/facts/accepted.dl")" >/dev/null 2>&1 || hatch_fresh_exit=$? if [ "$hatch_fresh_exit" -eq 2 ]; then - echo "PASS: FACTLOG_GATE_FAIL_OPEN=1 does NOT release the freshness deny (exit $hatch_fresh_exit)" + echo "PASS: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 does NOT release the freshness deny (exit $hatch_fresh_exit)" pass=$((pass + 1)) else - echo "FAIL: FACTLOG_GATE_FAIL_OPEN=1 — freshness deny must still fire (exit 2), got $hatch_fresh_exit" + echo "FAIL: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 — freshness deny must still fire (exit 2), got $hatch_fresh_exit" fail=$((fail + 1)) fi rm -rf "$KB_ENV7" From 157b5fcd902be8192c74ead03b7020c190de23dc Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 12:19:43 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EC=9D=BD=EC=A7=80=20=EB=AA=BB?= =?UTF-8?q?=ED=95=9C=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EA=B1=B4=EB=84=88=EB=9B=B4=20=EA=B2=80=EC=82=AC=EB=A5=BC=20?= =?UTF-8?q?=EB=93=9C=EB=9F=AC=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #323 이 지목한 결함의 절반은 조용함이었다 — "fail-open 경로는 stderr 한 줄도 남기지 않으므로 운영자는 게이트가 동작 중이라고 믿는다". 경로 추출을 고친 뒤에도 페이로드를 파싱하지 못한 경우와 추출기가 불완전한 레코드를 낸 경우는 여전히 무음 exit 0 이었다. 리졸버 저하를 한 줄로 관측 가능하게 만든 #244 의 선례와 어긋나는 유일한 지점이다. 이 두 갈래에만 stderr note 한 줄을 붙인다. 게이트가 읽지 못해 건너뛴 검사이므로 드러나야 한다. 나머지 fail-open 세 갈래(비쓰기 도구, tool_name 부재, 객체가 아닌 tool_input)는 건너뛴 검사가 아니라 평범한 트래픽이므로 무음을 유지한다 — 모든 도구 호출마다 한 줄씩 찍으면 노이즈다. 파싱 실패를 tool_input 부재와 구분하기 위해 추출기가 unparsed 종류를 따로 낸다. 거부 조건은 여전히 object 종류만 보므로 동작은 그대로다. 하니스에 두 갈래 케이스를 추가한다. 헤더가 열거한 fail-open 다섯 갈래 중 이 둘만 태우는 케이스가 없었다 — #323 이 오래 산 이유가 정확히 "계약 문구는 있는데 그 형태를 아무도 태우지 않았다" 였다. 불완전 레코드는 가용성 프로브와 리졸버에는 답하고 NUL 없는 출력을 내는 러너 shim 으로 재현한다. stale KB 의 엔진 입력을 겨냥하므로, 추출기가 멀쩡했다면 거부였을 입력이 exit 0 으로 나오는 것이 그 갈래를 탔다는 증거다. 업스트림 #323 --- hooks/gate_check.sh | 68 +++++++++++++++++++++++---------- tests/test_gate_check.sh | 82 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/hooks/gate_check.sh b/hooks/gate_check.sh index a5952620..7b68a46d 100755 --- a/hooks/gate_check.sh +++ b/hooks/gate_check.sh @@ -62,8 +62,10 @@ # either `tool_input` or the top level. That combination means the payload # schema drifted out from under the extractor while a write was in flight, # so the predicate cannot be evaluated for a write that may well target an -# engine input. Escape hatch: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1, which only a human -# can set (see the deny message below). +# engine input. Escape hatch: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1, +# which only a human can set (see the deny message below). The name states +# the one branch it releases: it must NOT grow into a switch that also +# releases branch 1 or 3, or the freshness predicate itself. # 3. _mtime() DENYs when `stat` cannot report a file's mtime. That branch has # NO escape hatch; it is only reachable for a file this script has already # seen pass `-f`, i.e. a race or an unreadable filesystem. @@ -71,18 +73,26 @@ # about the payload/interpreter layer. # # Everything else fails OPEN (exit 0): -# - an unparseable payload; -# - an absent `tool_name`; -# - a `tool_name` outside the write-class list; -# - a `tool_input` that is not a JSON object; -# - an INCOMPLETE record from the extractor — the interpreter died, or wrote -# something that is not three NUL-terminated fields. Note this lands the -# OPPOSITE way from branch 2's reasoning, deliberately: with no record at -# all we cannot tell that the call is even a write, so denying would block -# every tool call in the session on evidence we do not have. Branch 2 denies -# because we positively know it IS a write we cannot evaluate. +# - an unparseable payload; } these two emit a one-line +# - an INCOMPLETE record from the extractor — } stderr note, see below +# the interpreter died, or wrote something that is not three NUL-terminated +# fields. Note this lands the OPPOSITE way from branch 2's reasoning, +# deliberately: with no record at all we cannot tell that the call is even a +# write, so denying would block every tool call in the session on evidence +# we do not have. Branch 2 denies because we positively know it IS a write +# we cannot evaluate. +# - an absent `tool_name`; } ordinary traffic, +# - a `tool_name` outside the write-class list; } silent +# - a `tool_input` that is not a JSON object; } # A gate protecting two files in one KB must not become a global Write/Edit # outage, so anything short of positive knowledge falls open. +# +# The first two are a check the gate SKIPPED because it could not read the call. +# #323 was filed partly because such skips were silent and left the operator +# believing the gate was running, so they emit one stderr line each — the same +# observability rule the resolver degrade follows (#244). The last three are not +# skipped checks (a Read is simply not this gate's business), so they stay +# silent; a note on every tool call would be noise. set -euo pipefail @@ -170,17 +180,20 @@ fi GATE_EXTRACT_PY=" import json, sys PATH_KEYS = (\"file_path\", \"path\", \"notebook_path\") +UNPARSED = object() try: payload = json.load(sys.stdin) except Exception: - payload = None + payload = UNPARSED try: name = payload.get(\"tool_name\") tool_name = name if isinstance(name, str) else \"\" except Exception: tool_name = \"\" try: - if not isinstance(payload, dict) or \"tool_input\" not in payload: + if payload is UNPARSED: + input_kind = \"unparsed\" + elif not isinstance(payload, dict) or \"tool_input\" not in payload: input_kind = \"absent\" elif isinstance(payload[\"tool_input\"], dict): input_kind = \"object\" @@ -219,7 +232,7 @@ if ! { IFS= read -r -d '' tool_name \ # why this lands opposite to the narrow fail-closed branch below. tool_name="" target_path="" - tool_input_kind="absent" + tool_input_kind="incomplete" fi # Write-class tool names, matched EXACTLY. A user may register this hook with a @@ -248,22 +261,39 @@ if [ -z "$target_path" ]; then # tool calls from inside the session, so the deny message is addressed to a # human operator and says where to set it. if [ "${FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD:-}" = "1" ]; then - echo "[factlog GATE] note: could not read a target path from the $tool_name payload; FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 is set, so the write is allowed unchecked." >&2 + echo "[factlog GATE] note: could not read a target path from the $tool_name payload;" >&2 + echo " FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 is set, so the write is allowed unchecked." >&2 exit 0 fi echo "[factlog GATE] DENIED: could not read a target path from the $tool_name tool payload." >&2 echo " The hook payload schema changed, so the freshness predicate cannot be evaluated" >&2 echo " and this write cannot be shown to miss facts/accepted.dl or facts/query.dl." >&2 echo " This cannot be worked around from inside the session. Ask the operator to set" >&2 - echo " FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 in the Claude Code environment (the \"env\" block of" >&2 - echo " settings.json, or export it before launching Claude Code) and start a new" >&2 - echo " session. That bypasses only this check — the freshness deny still applies." >&2 + echo " FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 in the Claude Code environment (the" >&2 + echo " \"env\" block of settings.json, or export it before launching Claude Code) and" >&2 + echo " start a new session. That bypasses only this check — the freshness deny and the" >&2 + echo " Python-availability deny still apply." >&2 echo " Please also report the payload shape upstream." >&2 exit 2 fi # Fail OPEN for everything else: unparseable payload, an incomplete extractor # record, a non-write tool, an absent tool_name, or a tool_input that is not a # JSON object. + # + # The first two mean the gate silently skipped a call it could not read, which + # is exactly the "operator believes the gate is running" failure #323 was + # filed over. Make them OBSERVABLE with one stderr line, the same way the + # resolver degrade is (#244). The remaining three are ordinary traffic — a + # non-write tool or a payload shape with no tool_input is not a skipped check + # — so they stay silent rather than becoming noise on every tool call. + case "$tool_input_kind" in + unparsed) + echo "[factlog GATE] note: hook payload was not parseable JSON; the freshness gate was skipped for this call (fail-open)." >&2 + ;; + incomplete) + echo "[factlog GATE] note: the payload extractor returned no complete record; the freshness gate was skipped for this call (fail-open)." >&2 + ;; + esac exit 0 fi diff --git a/tests/test_gate_check.sh b/tests/test_gate_check.sh index 95361046..f48bb403 100644 --- a/tests/test_gate_check.sh +++ b/tests/test_gate_check.sh @@ -492,7 +492,7 @@ fi rm -rf "$KB_OK" "$ok_err" # =========================================================================== -# CASES 18-33: REAL HOOK ENVELOPE (#323) +# CASES 18-35: REAL HOOK ENVELOPE (#323) # # Claude Code does not send the bare tool input; it sends an envelope with the # tool input nested under `tool_input`. CASES 1-17 above all use the FLAT @@ -684,7 +684,85 @@ run_payload_case "envelope: engine-input path only inside content — allow (no "$KB_ENV7/notes.md" "$KB_ENV7/facts/accepted.dl")" 0 # --------------------------------------------------------------------------- -# CASE 33: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 escape hatch. +# CASE 33: UNPARSEABLE PAYLOAD — ALLOW, but say so. +# +# The header enumerates five fail-open branches; before this case none of the +# harness exercised the two that mean "the gate skipped a check it could not +# read". #323 survived precisely because a documented contract had no case +# riding it, so both are pinned here: exit 0 AND the one-line stderr note that +# keeps the skip visible to an operator (#244's rule). +# +# Garbage payload → json.load raises → fail open with a note. +# Empty payload → same branch (json.load("") raises too). +# --------------------------------------------------------------------------- +run_payload_case "unparseable payload — allow (fail-open)" \ + "$KB_ENV7" 'not json at all' 0 +run_payload_case "empty payload — allow (fail-open)" \ + "$KB_ENV7" '' 0 + +unparsed_err="$(mktemp)" +FACTLOG_ROOT="$KB_ENV7" bash "$GATE" <<< 'not json at all' >/dev/null 2>"$unparsed_err" || true +if grep -qF "hook payload was not parseable JSON" "$unparsed_err"; then + echo "PASS: unparseable payload emits a one-line fail-open note" + pass=$((pass + 1)) +else + echo "FAIL: unparseable payload — expected a fail-open note, got: $(cat "$unparsed_err")" + fail=$((fail + 1)) +fi +rm -f "$unparsed_err" + +# --------------------------------------------------------------------------- +# CASE 34: INCOMPLETE EXTRACTOR RECORD — ALLOW, but say so. +# +# The extractor writes exactly three NUL-terminated fields; if it dies or writes +# anything else, the shell has no record to reason about. That must fail OPEN +# (we cannot even tell the call is a write) and must NOT be silent. +# +# Simulated hermetically with a FACTLOG_PYTHON_RUNNER shim that answers the +# `import sys` availability probe and the resolver, then emits a record with no +# NUL terminators. The KB is stale and the payload targets an engine input, so a +# working extractor would DENY — reaching exit 0 proves the incomplete-record +# branch was taken. +# --------------------------------------------------------------------------- +TRUNC_SHIM="$(mktemp -d)" +cat > "$TRUNC_SHIM/runner.sh" <<'SH' +#!/usr/bin/env bash +# Behave like tools/factlog_python.sh for the probes the gate makes first, then +# hand back a record that is not three NUL-terminated fields. +for arg in "$@"; do + case "$arg" in + "import sys") exit 0 ;; + *resolve_root*) printf '%s' ""; exit 0 ;; + esac +done +printf 'Write no-nul-here' +exit 0 +SH +chmod +x "$TRUNC_SHIM/runner.sh" + +trunc_err="$(mktemp)" +trunc_exit=0 +FACTLOG_PYTHON_RUNNER="$TRUNC_SHIM/runner.sh" FACTLOG_ROOT="$KB_ENV7" \ + bash "$GATE" <<< "$(envelope Write "$KB_ENV7/facts/accepted.dl")" \ + >/dev/null 2>"$trunc_err" || trunc_exit=$? +if [ "$trunc_exit" -eq 0 ]; then + echo "PASS: incomplete extractor record — allow (fail-open) (exit $trunc_exit)" + pass=$((pass + 1)) +else + echo "FAIL: incomplete extractor record — expected fail-open (exit 0), got $trunc_exit" + fail=$((fail + 1)) +fi +if grep -qF "returned no complete record" "$trunc_err"; then + echo "PASS: incomplete extractor record emits a one-line fail-open note" + pass=$((pass + 1)) +else + echo "FAIL: incomplete extractor record — expected a fail-open note, got: $(cat "$trunc_err")" + fail=$((fail + 1)) +fi +rm -rf "$TRUNC_SHIM" "$trunc_err" + +# --------------------------------------------------------------------------- +# CASE 35: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 escape hatch. # # (a) It releases the narrow schema-drift deny of CASE 23 — without an escape # hatch, a payload-schema change turns this gate into a global Write/Edit From f4ad4cfeab598c314c8f0c2235989549dc8d4169 Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 12:24:15 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EA=B0=80?= =?UTF-8?q?=20=EB=88=88=EB=A8=BC=20=EC=93=B0=EA=B8=B0=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EB=A7=90=EC=9D=84=20=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋의 note 두 줄은 표적을 빗나갔다. 파싱 실패와 불완전 레코드만 계측했는데, 정작 개연성 높은 스키마 드리프트는 그 둘 중 어느 쪽도 아니다. tool_name 이 Write/Edit 인데 tool_input 이 없거나(키 이름이 바뀌거나) 객체가 아니면, JSON 은 멀쩡히 파싱되고 레코드도 3필드 완전한데 게이트는 대상 경로를 전혀 모른 채 통과시킨다. 실측 세 형태 모두 exit 0, stderr 0바이트였다. 이 갈래에도 note 를 찍는다. 거부가 아니므로 폭발반경은 없다. 앞 커밋 주석이 이 셋을 "ordinary traffic" 이라 적은 것은 측정으로 반증된다 — 쓰기 도구인데 경로를 못 읽은 것은 평범한 트래픽이 아니라 건너뛴 검사다. 노이즈 우려도 성립하지 않는다. 현행 스키마에서 Write/Edit 은 tool_input 객체를 항상 보내므로 정상 운영 중 0회 발화하고, 비쓰기 도구는 _is_write_tool 가드가 이미 배제한다. 무음으로 남는 것은 목록 밖 도구와 tool_name 부재 둘뿐이다. 헤더 열거도 그에 맞게 고치고, 빠져 있던 _canon() 폴백(항상 더 관대한 쪽으로만 어긋난다)도 한 줄 적는다. 빈 문자열 file_path 의 거부 메시지가 원인을 오진하던 것도 고친다. "The hook payload schema changed" 라고 단정하면 운영자가 env var 설정과 세션 재시작이라는 틀린 처방을 받는다. 빈 경로일 가능성을 먼저 적고, 스키마인 경우에만 탈출구를 안내한다. 하니스에 세 갈래를 고정한다. - NUL 구분자 뮤테이션 핀. 쓰기·읽기를 동시에 \0 에서 \n 으로 되돌리면 기존 케이스 28건이 전부 통과한다 — M4 가 요구한 결정이 무보호였다. 엔진 입력 경로 뒤에 개행과 한 조각을 붙인 대상은 엔진 입력이 아니므로 허용이어야 하는데, 개행 구분에서는 레코드가 개행에서 갈려 2번 필드가 정확히 엔진 입력이 되고 stale 가드가 오발한다. 이 한 건이 그 뮤턴트를 죽인다(실측 28 passed / 1 failed). - 빈 file_path 는 stale KB 와 fresh KB 양쪽에서 거부. 이 거부가 엔진 입력 매칭보다 앞선다는 사실까지 고정한다. - 눈먼 쓰기 세 형태(tool_input 부재 / 키 개명 / 비객체)에서 note 가 나오는지, 그리고 같은 형태의 비쓰기 도구는 무음인지. 업스트림 #323 --- hooks/gate_check.sh | 90 +++++++++++++++++++++--------------- tests/test_gate_check.sh | 98 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 148 insertions(+), 40 deletions(-) diff --git a/hooks/gate_check.sh b/hooks/gate_check.sh index 7b68a46d..daa97f02 100755 --- a/hooks/gate_check.sh +++ b/hooks/gate_check.sh @@ -72,27 +72,33 @@ # Only branches 1 and 2 carry an escape hatch, and only branches 1 and 2 are # about the payload/interpreter layer. # -# Everything else fails OPEN (exit 0): -# - an unparseable payload; } these two emit a one-line -# - an INCOMPLETE record from the extractor — } stderr note, see below -# the interpreter died, or wrote something that is not three NUL-terminated -# fields. Note this lands the OPPOSITE way from branch 2's reasoning, -# deliberately: with no record at all we cannot tell that the call is even a -# write, so denying would block every tool call in the session on evidence -# we do not have. Branch 2 denies because we positively know it IS a write -# we cannot evaluate. -# - an absent `tool_name`; } ordinary traffic, -# - a `tool_name` outside the write-class list; } silent -# - a `tool_input` that is not a JSON object; } +# Everything else fails OPEN (exit 0). Three of those branches are a check the +# gate SKIPPED because it could not read the call, and each emits a one-line +# stderr note — #323 was filed partly because such skips were silent and left +# the operator believing the gate was running (same rule as the resolver +# degrade, #244): +# - an unparseable payload; +# - an INCOMPLETE record from the extractor — the interpreter died, or wrote +# something that is not three NUL-terminated fields. Note this lands the +# OPPOSITE way from branch 2's reasoning, deliberately: with no record at +# all we cannot tell that the call is even a write, so denying would block +# every tool call in the session on evidence we do not have. Branch 2 denies +# because we positively know it IS a write we cannot evaluate. +# - a write-class `tool_name` whose `tool_input` is absent or is not a JSON +# object. The JSON parses and the record is complete, so the two notes above +# do not cover it, yet the gate is just as blind. Under the current schema +# Write/Edit always send a `tool_input` object, so this fires zero times in +# normal operation. +# Two branches stay silent, and neither is a skipped check: +# - a `tool_name` outside the write-class list (a Read is not this gate's +# business); +# - a payload with no `tool_name` at all. +# One more permissive degrade exists further down: _canon() falls back to the +# raw path string if the canonicaliser cannot run, which can only make a match +# LESS likely (i.e. more permissive). It is silent, and predates #323. +# # A gate protecting two files in one KB must not become a global Write/Edit # outage, so anything short of positive knowledge falls open. -# -# The first two are a check the gate SKIPPED because it could not read the call. -# #323 was filed partly because such skips were silent and left the operator -# believing the gate was running, so they emit one stderr line each — the same -# observability rule the resolver degrade follows (#244). The last three are not -# skipped checks (a Read is simply not this gate's business), so they stay -# silent; a note on every tool call would be noise. set -euo pipefail @@ -266,26 +272,33 @@ if [ -z "$target_path" ]; then exit 0 fi echo "[factlog GATE] DENIED: could not read a target path from the $tool_name tool payload." >&2 - echo " The hook payload schema changed, so the freshness predicate cannot be evaluated" >&2 - echo " and this write cannot be shown to miss facts/accepted.dl or facts/query.dl." >&2 - echo " This cannot be worked around from inside the session. Ask the operator to set" >&2 - echo " FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 in the Claude Code environment (the" >&2 - echo " \"env\" block of settings.json, or export it before launching Claude Code) and" >&2 - echo " start a new session. That bypasses only this check — the freshness deny and the" >&2 - echo " Python-availability deny still apply." >&2 + echo " Either the tool call carried an empty path — in which case the write itself is" >&2 + echo " malformed and nothing needs configuring — or the hook payload schema changed, so" >&2 + echo " the freshness predicate cannot be evaluated and this write cannot be shown to" >&2 + echo " miss facts/accepted.dl or facts/query.dl." >&2 + echo " If it is the schema: this cannot be worked around from inside the session. Ask" >&2 + echo " the operator to set FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 in the Claude Code" >&2 + echo " environment (the \"env\" block of settings.json, or export it before launching" >&2 + echo " Claude Code) and start a new session. That bypasses only this check — the" >&2 + echo " freshness deny and the Python-availability deny still apply." >&2 echo " Please also report the payload shape upstream." >&2 exit 2 fi - # Fail OPEN for everything else: unparseable payload, an incomplete extractor - # record, a non-write tool, an absent tool_name, or a tool_input that is not a - # JSON object. - # - # The first two mean the gate silently skipped a call it could not read, which - # is exactly the "operator believes the gate is running" failure #323 was - # filed over. Make them OBSERVABLE with one stderr line, the same way the - # resolver degrade is (#244). The remaining three are ordinary traffic — a - # non-write tool or a payload shape with no tool_input is not a skipped check - # — so they stay silent rather than becoming noise on every tool call. + # Fail OPEN for everything else. Three of those branches mean the gate SKIPPED + # a check because it could not read the call, which is exactly the "operator + # believes the gate is running" half of #323, so each emits one stderr line + # the way the resolver degrade does (#244): + # - the payload was not parseable JSON; + # - the extractor returned no complete record; + # - the call IS a write-class tool, but its `tool_input` is missing or is not + # a JSON object (a renamed key, a changed nesting). The JSON parses and the + # record is complete here, so neither note above covers it — and under the + # current schema Write/Edit always send a `tool_input` object, so this note + # fires zero times in normal operation. + # Only two branches stay silent, and neither is a skipped check: a tool outside + # the write-class list (a Read is not this gate's business) and a payload with + # no `tool_name` at all. Those are ordinary traffic; a note on each would be + # noise on every tool call. case "$tool_input_kind" in unparsed) echo "[factlog GATE] note: hook payload was not parseable JSON; the freshness gate was skipped for this call (fail-open)." >&2 @@ -293,6 +306,11 @@ if [ -z "$target_path" ]; then incomplete) echo "[factlog GATE] note: the payload extractor returned no complete record; the freshness gate was skipped for this call (fail-open)." >&2 ;; + absent|other) + if _is_write_tool "$tool_name"; then + echo "[factlog GATE] note: the $tool_name payload carried no tool_input object, so no target path could be read; the freshness gate was skipped for this call (fail-open)." >&2 + fi + ;; esac exit 0 fi diff --git a/tests/test_gate_check.sh b/tests/test_gate_check.sh index f48bb403..c6a37613 100644 --- a/tests/test_gate_check.sh +++ b/tests/test_gate_check.sh @@ -492,7 +492,7 @@ fi rm -rf "$KB_OK" "$ok_err" # =========================================================================== -# CASES 18-35: REAL HOOK ENVELOPE (#323) +# CASES 18-38: REAL HOOK ENVELOPE (#323) # # Claude Code does not send the bare tool input; it sends an envelope with the # tool input nested under `tool_input`. CASES 1-17 above all use the FLAT @@ -684,7 +684,97 @@ run_payload_case "envelope: engine-input path only inside content — allow (no "$KB_ENV7/notes.md" "$KB_ENV7/facts/accepted.dl")" 0 # --------------------------------------------------------------------------- -# CASE 33: UNPARSEABLE PAYLOAD — ALLOW, but say so. +# CASE 33: NUL SEPARATOR — MUTATION PIN. +# +# The extractor separates its three fields with NUL because a path may legally +# contain a newline. Switching both the write and the read back to "\n" passes +# every other case in this file, so without this one that decision is unpinned. +# +# The target is the engine input path with a newline and one more segment glued +# on. That is NOT the engine input, and the KB is stale, so the gate must ALLOW. +# Under a newline separator the record splits at the newline, field 2 becomes +# exactly "/facts/accepted.dl", the stale-report guard fires, and the case +# gets exit 2 instead. +# --------------------------------------------------------------------------- +run_payload_case "envelope: newline inside file_path — allow (NUL separator pin)" \ + "$KB_ENV7" \ + "$(printf '{"tool_name":"Write","tool_input":{"file_path":"%s\\nsuffix"}}' \ + "$KB_ENV7/facts/accepted.dl")" 0 + +# --------------------------------------------------------------------------- +# CASE 34: EMPTY file_path — DENY, and pin it. +# +# `tool_input` is an object and `tool_name` is write-class, but the path is the +# empty string, so no target can be read and the narrow fail-closed branch +# fires. Note this deny happens BEFORE the engine-input match, so it holds even +# in a KB whose report is fresh — hence the second call below against a fresh +# KB. The deny text must therefore not diagnose "schema changed" as the only +# possible cause. +# --------------------------------------------------------------------------- +run_payload_case "envelope: empty file_path in a stale KB — deny" \ + "$KB_ENV7" '{"tool_name":"Write","tool_input":{"file_path":"","content":"x"}}' 2 + +KB_EMPTY_FRESH="$(mktemp -d)" +kb_fresh "$KB_EMPTY_FRESH" +run_payload_case "envelope: empty file_path in a FRESH KB — deny (runs before the match)" \ + "$KB_EMPTY_FRESH" '{"tool_name":"Write","tool_input":{"file_path":""}}' 2 +rm -rf "$KB_EMPTY_FRESH" + +# --------------------------------------------------------------------------- +# CASE 35: WRITE-CLASS CALL WITH NO tool_input OBJECT — ALLOW, but say so. +# +# The JSON parses and the extractor record is complete, so neither the +# unparseable nor the incomplete note covers these; the gate is nevertheless +# blind to the target. That is a skipped check, not ordinary traffic, so it must +# be observable. Under the current schema Write/Edit always send a `tool_input` +# object, so in normal operation this note never fires. +# +# Three shapes: tool_input missing entirely, a renamed key (the most plausible +# schema drift), and a tool_input that is not an object. +# --------------------------------------------------------------------------- +blind_note_case() { + local desc="$1" + local payload="$2" + + local err + err="$(mktemp)" + local actual_exit=0 + FACTLOG_ROOT="$KB_ENV7" bash "$GATE" <<< "$payload" >/dev/null 2>"$err" || actual_exit=$? + + if [ "$actual_exit" -eq 0 ] && grep -qF "carried no tool_input object" "$err"; then + echo "PASS: $desc — allow with a fail-open note (exit $actual_exit)" + pass=$((pass + 1)) + else + echo "FAIL: $desc — expected allow (exit 0) with a fail-open note; exit=$actual_exit stderr=$(cat "$err")" + fail=$((fail + 1)) + fi + rm -f "$err" +} + +blind_note_case "write-class call with no tool_input at all" \ + '{"tool_name":"Write"}' +blind_note_case "write-class call with a renamed tool_input key" \ + "$(printf '{"tool_name":"Write","toolInput":{"file_path":"%s"}}' "$KB_ENV7/facts/accepted.dl")" +blind_note_case "write-class call with a non-object tool_input" \ + '{"tool_name":"Edit","tool_input":"oops"}' + +# A non-write tool in the same shape must stay SILENT — the note must not become +# noise on every Read/Grep/Bash call in a session. +quiet_err="$(mktemp)" +quiet_exit=0 +FACTLOG_ROOT="$KB_ENV7" bash "$GATE" <<< '{"tool_name":"Read","tool_input":"oops"}' \ + >/dev/null 2>"$quiet_err" || quiet_exit=$? +if [ "$quiet_exit" -eq 0 ] && [ ! -s "$quiet_err" ]; then + echo "PASS: non-write tool with no tool_input object — allow, and silent (exit $quiet_exit)" + pass=$((pass + 1)) +else + echo "FAIL: non-write tool — expected a silent allow; exit=$quiet_exit stderr=$(cat "$quiet_err")" + fail=$((fail + 1)) +fi +rm -f "$quiet_err" + +# --------------------------------------------------------------------------- +# CASE 36: UNPARSEABLE PAYLOAD — ALLOW, but say so. # # The header enumerates five fail-open branches; before this case none of the # harness exercised the two that mean "the gate skipped a check it could not @@ -712,7 +802,7 @@ fi rm -f "$unparsed_err" # --------------------------------------------------------------------------- -# CASE 34: INCOMPLETE EXTRACTOR RECORD — ALLOW, but say so. +# CASE 37: INCOMPLETE EXTRACTOR RECORD — ALLOW, but say so. # # The extractor writes exactly three NUL-terminated fields; if it dies or writes # anything else, the shell has no record to reason about. That must fail OPEN @@ -762,7 +852,7 @@ fi rm -rf "$TRUNC_SHIM" "$trunc_err" # --------------------------------------------------------------------------- -# CASE 35: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 escape hatch. +# CASE 38: FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1 escape hatch. # # (a) It releases the narrow schema-drift deny of CASE 23 — without an escape # hatch, a payload-schema change turns this gate into a global Write/Edit