요약
hooks/gate_check.sh 가 훅 페이로드에서 대상 경로를 최상위 키에서만 찾는다.
Claude Code 의 PreToolUse 페이로드는 그 값을 tool_input 아래에 중첩해서 보낸다.
경로 추출에 실패하면 스크립트는 명시적으로 fail-open(exit 0) 한다.
결과: facts/accepted.dl · facts/query.dl 에 대한 Write/Edit 이 리포트 신선도와
무관하게 항상 허용된다. SKILL.md 가 "Deterministic gate (do not skip)" 로 못박고
hooks/hooks.json 이 PreToolUse 로 등록한 그 장치가 프로덕션에서 한 번도 발동하지 않는다.
재현 (upstream/main c6d359d, macOS, Python 3.12 로 실측)
KB=$(mktemp -d)/kb; mkdir -p "$KB/facts"
: > "$KB/facts/accepted.dl" # 엔진 입력은 있고 logic_report.txt 는 없음 -> DENY 여야 함
# (A) 저장소 테스트가 쓰는 평평한 형태
FACTLOG_ROOT="$KB" bash hooks/gate_check.sh \
<<< "{\"file_path\":\"$KB/facts/accepted.dl\"}"; echo "exit=$?"
# [factlog GATE] DENIED: facts/logic_report.txt does not exist.
# exit=2 <- 의도된 동작
# (B) Claude Code 가 실제로 보내는 형태
FACTLOG_ROOT="$KB" bash hooks/gate_check.sh \
<<< "{\"session_id\":\"s\",\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$KB/facts/accepted.dl\",\"content\":\"x\"}}"; echo "exit=$?"
# exit=0 <- 게이트가 무력하다 (경고 한 줄도 없음)
근거
hooks/gate_check.sh:103-106
# 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)"
주석은 "Claude Code sends the tool input as JSON on stdin" 이라고 적었지만, 실제로
전달되는 것은 tool input 을 감싼 봉투다.
{
"session_id": "...", "cwd": "...", "hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": { "file_path": "...", "content": "..." },
"tool_use_id": "..."
}
이어지는 fail-open 분기(:108-114)의 정당화가 이 경우를 커버하지 못한다.
# 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
exit 0
fi
"빈 페이로드는 엔진 입력을 겨냥할 수 없다" 는 전제가 참이 아니다. 페이로드는 비어 있지
않고 엔진 입력을 정확히 겨냥하고 있는데, 파서가 그 키를 보지 않을 뿐이다.
게이트의 다른 부분(KB root 해석 #239/#240, stale/bootstrap 판정, mtime 비교)은 (A) 에서
보듯 정상 동작한다. 결함은 오직 대상 경로 추출 한 곳이다.
영향
- 심각도 높음. 리포트가 없거나 낡았을 때 엔진 입력 편집을 막는 유일한 자동 장치가
전면 무효다. tests/test_gate_check.sh 는 평평한 페이로드만 넣기 때문에 전 케이스
PASS 하면서 이 결함을 통과시킨다.
- 조용한 실패다. fail-open 경로는 stderr 한 줄도 남기지 않으므로, 운영자는 게이트가
동작 중이라고 믿는다.
제안
tool_input 을 먼저 보고, 없으면 최상위로 폴백해 경로를 뽑는다
(d.get('tool_input') or d 에서 file_path/path 조회).
- 페이로드가 엔진 입력 형태의 쓰기(
tool_name 이 Write/Edit)인데 경로를 못 뽑으면
fail-open 하지 말고 deny 하거나, 최소한 stderr 로 표면화한다.
tests/test_gate_check.sh 에 실제 중첩 페이로드 케이스를 추가해 회귀를 고정한다.
현재 하니스가 쓰는 평평한 형태만으로는 이 결함이 영원히 잡히지 않는다.
수용 기준
요약
hooks/gate_check.sh가 훅 페이로드에서 대상 경로를 최상위 키에서만 찾는다.Claude Code 의 PreToolUse 페이로드는 그 값을
tool_input아래에 중첩해서 보낸다.경로 추출에 실패하면 스크립트는 명시적으로 fail-open(exit 0) 한다.
결과:
facts/accepted.dl·facts/query.dl에 대한 Write/Edit 이 리포트 신선도와무관하게 항상 허용된다. SKILL.md 가 "Deterministic gate (do not skip)" 로 못박고
hooks/hooks.json이 PreToolUse 로 등록한 그 장치가 프로덕션에서 한 번도 발동하지 않는다.재현 (upstream/main c6d359d, macOS, Python 3.12 로 실측)
근거
hooks/gate_check.sh:103-106주석은 "Claude Code sends the tool input as JSON on stdin" 이라고 적었지만, 실제로
전달되는 것은 tool input 을 감싼 봉투다.
{ "session_id": "...", "cwd": "...", "hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": { "file_path": "...", "content": "..." }, "tool_use_id": "..." }이어지는 fail-open 분기(
:108-114)의 정당화가 이 경우를 커버하지 못한다."빈 페이로드는 엔진 입력을 겨냥할 수 없다" 는 전제가 참이 아니다. 페이로드는 비어 있지
않고 엔진 입력을 정확히 겨냥하고 있는데, 파서가 그 키를 보지 않을 뿐이다.
게이트의 다른 부분(KB root 해석 #239/#240, stale/bootstrap 판정, mtime 비교)은 (A) 에서
보듯 정상 동작한다. 결함은 오직 대상 경로 추출 한 곳이다.
영향
전면 무효다.
tests/test_gate_check.sh는 평평한 페이로드만 넣기 때문에 전 케이스PASS 하면서 이 결함을 통과시킨다.
동작 중이라고 믿는다.
제안
tool_input을 먼저 보고, 없으면 최상위로 폴백해 경로를 뽑는다(
d.get('tool_input') or d에서file_path/path조회).tool_name이 Write/Edit)인데 경로를 못 뽑으면fail-open 하지 말고 deny 하거나, 최소한 stderr 로 표면화한다.
tests/test_gate_check.sh에 실제 중첩 페이로드 케이스를 추가해 회귀를 고정한다.현재 하니스가 쓰는 평평한 형태만으로는 이 결함이 영원히 잡히지 않는다.
수용 기준
tool_input.file_path)로 엔진 입력을 겨냥하면 리포트 부재/stale 시 exit 2.tests/test_gate_check.sh가 두 페이로드 형태를 모두 태운다.