Skip to content

fix: PreToolUse 게이트가 실제 훅 페이로드에서 경로를 못 찾아 항상 fail-open 한다 #323

Description

@SeoyunL

요약

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 한 줄도 남기지 않으므로, 운영자는 게이트가
    동작 중이라고 믿는다.

제안

  1. tool_input 을 먼저 보고, 없으면 최상위로 폴백해 경로를 뽑는다
    (d.get('tool_input') or d 에서 file_path/path 조회).
  2. 페이로드가 엔진 입력 형태의 쓰기(tool_name 이 Write/Edit)인데 경로를 못 뽑으면
    fail-open 하지 말고 deny 하거나, 최소한 stderr 로 표면화한다.
  3. tests/test_gate_check.sh실제 중첩 페이로드 케이스를 추가해 회귀를 고정한다.
    현재 하니스가 쓰는 평평한 형태만으로는 이 결함이 영원히 잡히지 않는다.

수용 기준

  • 중첩 페이로드(tool_input.file_path)로 엔진 입력을 겨냥하면 리포트 부재/stale 시 exit 2.
  • 평평한 페이로드의 기존 동작 불변(회귀 없음).
  • bootstrap 허용 분기가 중첩 페이로드에서도 exit 0 을 유지.
  • 엔진 입력이 아닌 경로는 두 형태 모두 exit 0.
  • tests/test_gate_check.sh 가 두 페이로드 형태를 모두 태운다.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions