fix: 게이트가 실제 훅 페이로드에서 대상 경로를 읽는다 - #332
Conversation
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
PreToolUse 게이트는 이제 신선도 외에 한 가지 사유로 더 거부한다. Write/Edit 호출인데 페이로드에서 대상 경로를 읽을 수 없으면, 그 쓰기가 엔진 입력을 겨냥하지 않는다고 보일 수 없으므로 통과시키지 않는다. 사용자에게 보이는 새 거부이므로 결정론 가이드 ko/en 쌍에 사유와 FACTLOG_GATE_FAIL_OPEN=1 탈출구를 함께 적는다. 이 변수가 신선도 거부까지 풀지는 않는다는 점도 명시한다. 업스트림 #323
스키마 드리프트 거부의 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
이 수정 전까지 훅은 실제 페이로드를 읽지 못해 신선도 거부를 한 번도 내지 않았다. 따라서 facts/accepted.dl 이나 facts/query.dl 이 이미 있고 리포트가 없거나 낡은 기존 사용자는, 이번 수정 이후 모델의 Write/Edit 에서 처음으로 하드 거부를 받는다. 앞선 문서 문단은 새 스키마 드리프트 거부만 설명했고 이 전환을 다루지 않았다. 레포에 CHANGELOG 가 없으므로 결정론 가이드에 적는다. /factlog check 를 먼저 돌리면 편집이 풀린다. FACTLOG_GATE_FAIL_OPEN 도 설정 위치가 빠져 있었다. 훅은 Claude Code 프로세스의 환경을 물려받으므로 세션 안에서 모델이 스스로 켤 수 없다 — 사람이 settings.json 의 env 블록에 넣거나 실행 전 export 한 뒤 새 세션을 시작해야 한다. ko/en 쌍에 동일하게 반영한다. 업스트림 #323
FACTLOG_GATE_FAIL_OPEN 은 실제보다 넓은 약속을 하는 이름이었다. 이 변수가 푸는 것은 "판정 불가 페이로드" 한 갈래뿐이고 신선도 거부와 python 부재 거부는 그대로 두는데, 이름은 게이트 전체를 여는 스위치처럼 읽힌다. 다음 사람이 이름값을 하도록 범위를 넓히고 싶어질 이름이다. FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD 로 바꾼다. 면제 갈래가 이름에 박혀 있어 확장 유혹이 원천 차단된다. env var 이름은 공개 계약이라 릴리스 뒤에 바꾸면 breaking change 이므로 지금 바꾼다 — 옛 이름은 어디에도 남기지 않는다. 코드·거부 메시지·주석·하니스 케이스와 결정론 가이드 ko/en 을 일괄로 바꿨다. 동작은 그대로다. 업스트림 #323
이슈 #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
앞 커밋의 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
justinjoy
left a comment
There was a problem hiding this comment.
결론: CHANGE REQUEST
핵심 진단과 수정은 옳다. tool_input 중첩이 실제 PreToolUse 페이로드 형태라는 점, matcher 가 Write|Edit 완전일치 목록이라 MultiEdit/NotebookEdit 는 도달하지 않는다는 점, 수정 전 게이트가 실제 페이로드에서 항상 fail-open 했다는 점을 모두 독립적으로 재현했다. 하니스도 강하다 — PR head 에서 47/47 통과, 신규 하니스를 pre-fix 게이트에 걸면 33 passed / 14 failed 로 PR 본문이 적은 6건보다 오히려 넓게 재현된다. NUL→\n 뮤테이션도 본문 주장대로 CASE 33 한 건만 죽인다.
머지를 막는 것은 아래 항목들이다. 전부 국소 수정이다.
Blocker
1. 문서가 코드가 구현하지 않은 거부 규칙을 설명한다.
docs/guide/determinism.md(+.en.md)는 "페이로드 구조가 바뀌어 그 경로를 전혀 읽을 수 없으면 거부한다"고 적었다. 실제로는 tool_input 이 JSON 객체로 존재할 때만 거부한다(hooks/gate_check.sh:257). 봉투 키 자체가 개명되면 통과시킨다(:309-315) — 이 PR 의 테스트가 그 동작을 고정하고 있다:
bash hooks/gate_check.sh <<< '{"tool_name":"Write","toolInput":{"file_path":"<KB>/facts/accepted.dl"}}'
→ exit 0 # 엔진 입력 경로가 페이로드에 그대로 보이는데도 통과
문서를 실제 조건("tool_input 이 객체로 있으나 읽을 수 있는 경로 키가 없는 경우, 빈 file_path 포함")으로 좁히고 "페이로드 구조가 바뀌면" 이라는 일반화 표현을 빼야 한다.
2. PR 본문의 뮤테이션 귀속 한 건이 실행으로 반증된다.
d.get('tool_input') or d 뮤턴트를 실제로 적용한 결과 46 passed / 1 failed 이고, 죽이는 것은 CASE 28(top-level 폴백 → deny)이다. CASE 26·27 은 이 뮤턴트를 통과한다. CASE 26/27 이 죽이는 것은 우선순위 역전(for source in (payload, nested)) 뮤턴트이고, 그때는 45 passed / 2 failed 로 정확히 26·27 이 죽는다. tests/test_gate_check.sh:638-639 주석도 "either case alone is passed by an implementation that simply merges the two dicts" 라고 이미 적고 있다.
테스트 스위트 자체는 충분하다(뮤턴트는 어쨌든 죽는다). 다만 이 PR 의 머지 근거가 뮤테이션 표 자체이므로, 귀속을 정정해야 근거의 신뢰도가 유지된다.
3. 이 PR 이 처음으로 발동시키는 불변식에 문서화·테스트되지 않은 대소문자 구멍이 있다.
헤더 :11-13 은 "TARGET is an engine input iff it resolves to <KB_ROOT>/facts/accepted.dl OR query.dl" 이라는 반증 가능한 명제를 세우는데, 대소문자 비구분 파일시스템(macOS APFS 기본, Windows)에서 거짓이다. 같은 KB, 같은 inode 로 실측:
.../facts/accepted.dl → exit 2 (DENY)
.../facts/Accepted.dl → exit 0 (ALLOW)
.../FACTS/accepted.dl → exit 0 (ALLOW)
_canon() 은 이 PR 이 건드리지 않은 선행 동작이지만, 수정 전에는 게이트가 프로덕션에서 발동하지 않았으므로 의미가 없었다. 이 불변식을 실효화하고 그 주변에 29건을 새로 고정하며 _canon raw fallback 같은 다른 degrade 는 전부 열거한 PR 이므로, 이 구멍만 무기재인 것은 같은 기준을 통과하지 못한다. os.path.normcase/samefile 로 비교하거나, 헤더 degrade 목록과 문서에 한계로 명시하고 어느 쪽이든 케이스로 고정해달라. (Linux 에서는 Accepted.dl 이 실제로 다른 파일이므로 동작을 바꾸는 쪽은 판단이 필요하다.)
4. 새로 추가한 fail-open 관측성 노트가 실제로는 아무에게도 표시되지 않는다.
훅 계약상 exit 0 에서는 stdout 이 JSON 제어 출력으로만 파싱되고 평문 stdout·stderr 은 폐기된다. 사용자에게 stderr 이 노출되는 것은 exit 0/2 이외의 종료 코드이고, 모델에게 전달되는 것은 exit 2 다. 이 PR 이 추가한 노트 4곳(:270-271 탈출구 활성, :304, :307, :311)은 전부 exit 0 경로다.
그런데 헤더 :75-79 와 문서는 "#323 이 제기된 이유의 일부가 이 건너뜀이 무음이었기 때문" 이라며 이 노트가 그 문제를 해소한다고 명시한다. 특히 :270-271(탈출구를 켜고 잊으면 게이트가 영구히 우회됨)에 신호가 없는 것은 안전 회귀다. 그리고 CASE 35/36/37 은 스크립트 stderr 을 2>"$err" 로 직접 캡처해 검증한다 — 실제 하니스가 아니라 합성 픽스처를 상대로 계약을 고정하는, 지금 고치고 있는 것과 동일한 실패 유형이다.
둘 중 하나를 택해달라: (a) {"hookSpecificOutput":{...,"permissionDecision":"allow"},"systemMessage":"..."} 로 전환하거나 비차단 non-zero exit 을 쓰고 CASE 35-37 이 표면화되는 채널을 단언하게 하거나, (b) 채널을 안 바꾼다면 헤더·문서에서 operator 에게 보인다는 주장을 삭제하고 외부 캡처 전용임을 명시.
5. Write/Edit 호출당 지연이 2배가 된다 — 버그가 가리고 있던 비용이 이제 청구된다.
동일 머신, 비-KB 대상 실제 봉투 페이로드 5회:
PR #332 : ~400ms/call (Python 스폰 6회)
main : ~200ms/call (Python 스폰 3회)
수정 전에는 target 이 항상 비어 :256 에서 조기 종료했다. 이제 항상 채워지므로 _canon() 이 target/accepted.dl/query.dl 3회를 추가로 스폰한다. 이 비용은 KB 파일이 아니라 플러그인이 설치된 모든 세션의 모든 Write/Edit 에 붙는다. 파일 2개를 지키는 게이트로서 :100-101 이 스스로 세운 비례성 원칙과 어긋난다.
_canon 호출 직전에 basename 프리필터를 두면 사실상 모든 쓰기에서 원복된다(basename 이 다르면 어떤 정준화로도 엔진 입력이 될 수 없고, 심링크로 이름이 바뀌는 경우는 _canon 이 해석한 뒤 basename 이 일치한다):
case "$target_path" in
*/accepted.dl|*/query.dl|accepted.dl|query.dl) ;;
*) exit 0 ;;
esac단락 동작을 고정하는 케이스 1건(예: stale KB 에서 facts/accepted.dl.bak → ALLOW)도 함께 넣어달라.
Should fix
6. /factlog check 가 실패하는 KB 에서 실행 불가능한 거부 루프가 새로 도달 가능해진다.
tools/run_logic_check.py:220 의 리포트 쓰기는 run_wirelog() 뒤에 있고, factlog/common.py:1634 는 엔진 로드 실패·accepted.dl 부재 시 예외를 던진다 — 즉 엔진이 못 돌면 리포트는 아예 생성되지 않는다. 엔진이 깨진 환경에서 실측:
facts/query.dl 존재 + logic_report.txt 없음
→ run_logic_check.py 실패, 리포트 미생성
→ 게이트 exit 2 "DENIED ... Run /factlog check"
→ /factlog check 실패 → 1로 복귀
신선도 거부에는 의도적으로 탈출구가 없다(CASE 38b 가 고정). matcher 가 Write|Edit 뿐이라 Bash 로 복구 가능하고 폭발 반경도 KB 하나의 2파일이므로 blocker 로 올리진 않았으나, 이 PR 이 추가한 문서 문단이 바로 그 복구 경로를 적을 자리다. 근본 수정(엔진 실패 시에도 실패를 기록한 리포트를 쓰기)은 별도 이슈로 분리해도 좋다.
7. 헤더의 fail-closed/fail-open 비대칭 서술이 자기 근거로 지지되지 않는다.
:83-86 은 "record 가 아예 없으면 쓰기인지조차 알 수 없다 / branch 2 는 쓰기임을 확실히 안다" 로 두 분기를 가르는데, fail-open 분기에서도 _is_write_tool "$tool_name" 을 읽어 노트를 찍는다(:310) — 쓰기임을 똑같이 안다. 두 분기의 인식론적 상태는 동일하고 다른 것은 드리프트의 형태뿐이다. 결론(내부 키 개명이 봉투 키 개명보다 유력하므로 그쪽만 fail-closed)은 방어 가능하다고 보나, 헤더가 이를 원칙이 아니라 의도적 확률 베팅으로 서술해야 후속 유지보수자가 잘못 일반화하지 않는다.
Nit
hooks/gate_reminder.sh:17은 페이로드 전체를 grep 해서{"file_path":"/tmp/notes.md","content":"see facts/accepted.dl"}에 오탐한다. 이 PR 의 스코프 밖이지만 같은 이슈 계열이므로 후속으로 올려두면 좋겠다.- JSON
file_path안에 NUL 이 있으면 레코드가 밀려:302-314의case가 어느 arm 에도 안 걸려 stderr 한 줄 없이 exit 0 한다. 영향은 0(OS 가 그런 경로를 거부)이지만 무음이라는 점이 헤더의 "무음 아님" 열거와 어긋난다.*)arm 한 줄이면 된다. - 탈출구가 branch-1 Python 부재 거부를 풀지 않는다는 절반이 테스트로 고정돼 있지 않다(신선도 쪽만 CASE 38b 로 고정). 수동으로는 확인했다.
:230의tool_input_kind="absent"는 세 번째read나 실패 분기의"incomplete"로 항상 덮여 죽은 대입이다.- mtime 동치 경계(
:397이-lt라 같은 초는 ALLOW)에 케이스가 없다.run_logic_check.py가 같은 초에 리포트를 쓰므로 흔한 경우다. :267-268의 "모델이 자기 도구 호출에 env var 를 붙일 수 없다" 는 과한 표현이다 — 모델은 Bash 로settings.json을 쓸 수 있다. 실제로 막는 것은 새 세션이 필요하다는 점이고 메시지에 이미 있다.
확인하지 못한 것
pytest tests/unit 655 passed / 13 skipped 는 이 환경에서 재현하지 못했다(libwirelog 링크 파손으로 수집 단계 오류 37건). 이 PR 이 Python 을 건드리지 않고 CI 가 green 이므로 판단에는 반영하지 않았다.
Closes #323.
무엇을 고쳤나
hooks/gate_check.sh가 훅 페이로드의 최상위에서만file_path를 찾았다. Claude Code 는 그 값을tool_input아래에 중첩해 보낸다. 경로 추출에 실패하면 스크립트는 fail-open(exit 0) 하므로,SKILL.md가 "Deterministic gate (do not skip)" 로 못박은 장치가 프로덕션에서 한 번도 발동하지 않았다.tool_input을 먼저 보고 최상위로 키 단위 폴백한다. 이슈 본문이 예시로 든d.get('tool_input') or d는 혼합 페이로드에서 실패하므로 쓰지 않았다 —tool_input이 truthy 라 최상위를 못 본다. 두 dict 을 merge 하는 구현도 통과하지 못하도록 우선순위를 양방향으로 고정했다.조사하며 정정한 전제 둘
matcher 는 부분일치가 아니라 완전일치다. Claude Code 2.1.220 의 매칭 함수는 matcher 가
^[a-zA-Z0-9_|]+$를 만족하면.includes(toolName)분기로 간다. 같은 모듈의 경고 문자열도 "it is compared as an exact string" 이라고 적는다(CHANGELOG v2.1.195). 따라서 이 훅에 도달하는 도구는{Write, Edit}둘뿐이고MultiEdit·NotebookEdit는 도달하지 않는다.notebook_path조회는 남겼지만 "현재 도달 불가, matcher 를 스스로 넓힌 사용자 대비 방어" 로 주석에 정직하게 적었다.평평한 페이로드는 실제로 존재하지 않는다. 페이로드 생성부에 최상위
file_path키가 없다. 최상위 폴백이 지키는 것은 하니스 픽스처 17건이며, 그 사실을 주석에 남겨 "실제 페이로드 두 형태를 지원한다" 는 오독을 막았다.fail-closed 는 좁히고 탈출구를 함께 뒀다
이슈 제안대로 "쓰기인데 경로를 못 뽑으면 deny" 를 그대로 하면 파싱 불가·빈 페이로드까지 거부해, 스키마가 바뀌는 날 전 세션의 Write/Edit 이 마비된다. 이 게이트가 지키는 것은 KB 하나의 파일 2개다 — 손실이 비대칭이다.
세 조건 AND 로 좁혔다:
tool_input이 파싱된 객체로 존재 +tool_name∈ {Write, Edit} 완전일치 + 어디서도 경로를 못 읽음.그리고
FACTLOG_GATE_ALLOW_UNREADABLE_PAYLOAD=1이 이 거부만 푼다 — 신선도 거부와 Python 부재 거부는 그대로다(실측). 선례가 있다: Python 가용성 fail-closed 도FACTLOG_PYTHON탈출구를 함께 배포하고 하니스가 그걸 태운다. 이름에 갈래를 박은 이유는 env var 이름이 공개 계약이라 릴리스 후엔 breaking change 이고, 넓은 이름은 다음 사람이 "이름값을 하게" 확장하고 싶어지기 때문이다.거부 메시지는 사람 대상이다. 훅은 Claude Code 프로세스 환경을 상속하므로 모델이 자기 도구 호출에 env var 를 붙일 수 없다 — 설정 위치(settings.json
env블록 / 실행 전 export)와 새 세션 필요를 안내한다.눈먼 쓰기도 계측한다
이슈 본문이 결함의 일부로 지목한 것이 "fail-open 경로는 stderr 한 줄도 남기지 않으므로 운영자는 게이트가 동작 중이라고 믿는다" 였다. write-class
tool_name인데tool_input이 없거나 객체가 아닌 세 형태는 JSON 이 멀쩡히 파싱되고 레코드도 완전해서, 파싱 실패용 note 가 덮지 못한다. 그런데 봉투 키 개명이 가장 개연성 높은 드리프트 형태다. 이 갈래에 note 를 추가했다 — 거부가 아니라 note 이므로 폭발반경 0 이고, 현행 스키마에서 Write/Edit 은tool_input을 항상 보내므로 정상 운영 중 0회 발화한다.무음으로 남는 둘(목록 밖 도구,
tool_name부재)은 건너뛴 검사가 아니라 정상 트래픽이다.검증
upstream/main에서 실패(중첩 deny, stale, 경로 키 전무, 우선순위 양방향, 탈출구가 신선도를 안 품). 나머지는 "회귀 방지 핀 / vacuous pre-fix" 로 라벨했다 — 수정 전 게이트는 모든 중첩 페이로드에 exit 0 을 내므로 exit 0 을 기대하는 중첩 케이스는 전부 vacuous 다\n으로 되돌리면 새 CASE 33 한 건만 죽는다 — 그 결정이 그동안 무보호였다는 뜻이다.d.get('tool_input') or d뮤턴트는 CASE 26/27 이 죽인다pytest tests/unit655 passed / 13 skipped(변경 없음),uvx ruff@0.15.17 check .cleantests/*.sh40개 —upstream/main과 동일머지 후 주의
이 수정이 들어가면 게이트가 프로덕션에서 처음 발화한다.
facts/accepted.dl이나facts/query.dl이 이미 있는데facts/logic_report.txt가 없거나 낡은 기존 KB 는 그 시점부터 엔진 입력 편집에 하드 거부를 받는다./factlog check를 한 번 돌리면 해소된다. 레포에 CHANGELOG 가 없어docs/guide/determinism.md(+.en.md)에 적었다.알려진 한계 (수용, 주석에 기재)
_mtime거부에는 탈출구가 없다(-f통과 직후의 레이스나 읽을 수 없는 FS 에서만 도달)/dev/fd없으면 FIFO 폴백이라 실무 도달 불가로 판단했다file_path는 리포트가 신선해도 거부된다. 메시지가 원인을 단정하지 않도록 고쳤고 케이스로 고정했다_canon()폴백은 여섯 번째 관대한 degrade 다(항상 매칭을 덜 되게만 만든다).upstream/main부터 있던 동작이라 손대지 않고 열거만 했다