TIL/Playwright

[TIL][Claude Code] 게이트는 깨봐야 게이트다 — Playwright 규율 훅이 조용히 죽는 세 가지 방식

아람2 2026. 9. 5. 20:00
반응형
# PreToolUse(Edit|Write) 훅 — 스텝 파일에 raw 로케이터가 들어오면 exit 2 로 차단 
if grep -qE '\bpage\.locator\(' <<< "$NEW_TEXT"; then 
  echo "🚫 로케이터는 pages/*Page.ts 에만" >&2 
  exit 2 
fi 

이 훅이 오늘도 막고 있을 거라 믿으면, 함정에 걸린 거다

훅은 세운 날 동작한다, 그리고 그 뒤로 아무도 다시 확인하지 않는다

훅으로 Playwright 규율을 강제하고 있었다

Claude Code 로 E2E 시나리오를 짜게 하면서 규율을 CLAUDE.md 에 적어 두는 것으로는 부족했다

로케이터는 Page Object 에만 두고 스텝은 POM 메서드만 호출한다,

이유 없는 test.skip 은 금지다, 커밋 제목은 영어다 —
이런 규칙은 에이전트가 급해지면 가장 먼저 무너진다

그래서 규칙을 훅으로 옮겼다

PreToolUse(Edit|Write)  → 스텝에 page.locator / getBy* / XPath 가 들어오면 차단
PreToolUse(Edit|Write)  → test.only, 이유 없는 test.skip 차단
PreToolUse(Bash)        → git commit 인데 커밋 메시지 규칙 위반이면 차단
PreToolUse(Bash)        → 로직 파일 커밋인데 적대적 코드리뷰 2회가 없으면 차단

몇 주 동안 잘 돌았다, 정확히는 잘 도는 것처럼 보였다

전수 감사를 하면서 훅을 하나씩 뜯어봤더니, 죽어 있는 규칙과 열려 있는 구멍이 한 훅에 서너 개씩 나왔다

죽는 방식은 셋으로 갈렸다

규칙 하나를 고쳤는데 옆 규칙이 죽었다

스텝 훅에 .page.* 직접 접근 차단 규칙을 새로 넣었다

appPage.page.waitForEvent(...) 처럼 POM 캡슐을 우회하는 코드가

기존 locator/getBy 열거만으로는 다 열려 있었기 때문이다

그런데 이 규칙은 주석에서 오탐이 났다 — // appPage.page.route 를 쓰지 말 것 같은 산문 주석이 편집을 막았다

해결로 주석을 지우고 검사하는 전처리를 넣었다

# ❌ 전처리를 전 규칙에 공통으로 걸었다
nocomment = re.sub(r"(?m)//.*$", "", text)
for pat, name in rules:
    if re.search(pat, nocomment): violations.append(name)

그 순간 XPath 규칙이 통째로 죽었다

XPath 리터럴은 정의상 //div[@id='x'] 로 시작한다,
주석 제거가 // 뒤를 먼저 지워 버려 XPath 규칙은 빈 문자열을 검사하게 됐다

한 규칙의 오탐을 막으려 넣은 전처리가 다른 규칙의 미탐이 된 것이다

이 회귀는 셀프체크가 없던 시점이라 26개 입력을 손으로 HEAD 판본과 대조해서야 드러났다

고친 방향은 두 가지다

# ✅ 주석 제거는 줄머리 앵커로만, 그리고 그것을 필요로 하는 규칙에만 적용한다
nocomment = re.sub(r"(?m)^\s*(?://|\*|/\*).*$", "", text)
rules = [
    (r"\bpage\.locator\(",          "page.locator 직접 호출",  False),
    (r"\.page\.[a-zA-Z]",           ".page.* 직접 접근",       True),   # 이 규칙만 주석 제외
    (r"xpath=|[\"'`]//[\w*@\[]",    "XPath 사용",             False),
]
for pat, name, strip_comments in rules:
    if re.search(pat, nocomment if strip_comments else text):
        violations.append(name)

줄머리 앵커가 아니면 문자열 안의 https://...'/*.cif' 도 주석으로 오인돼 뒤 코드가 통째로 삭제된다

전처리 라우팅은 규칙 튜플 안에 박는다 — 패턴 문자열 동등비교로 분기하면 패턴 한 글자만 고쳐도 조용히 원문 매칭으로 돌아간다

게이트가 "못 돌면 통과" 로 만들어져 있었다

두 번째 방식은 규칙이 죽은 게 아니라 규칙이 한 번도 실행되지 않은 경우다

리뷰 게이트는 Claude Code 의 transcript JSONL 을 읽어 "마지막 편집 이후 적대적 리뷰가 2회 있었나" 를 센다

그 코드의 첫 줄이 이랬다

# ❌ transcript 를 못 읽으면 조용히 통과
if not transcript_path or not os.path.isfile(transcript_path):
    sys.exit(0)

같은 형태가 셋 더 있었다

  • 임시 트랜스파일이 실패하면 || exit 0
  • 커버리지 근거 검증기가 제품 레포 경로를 잘못 조립해 빈 목록을 받으면 return true
  • git diff --cached 로 커밋 대상을 잡아서, git add -A && git commit 한 줄이면 훅이 add 전에 돌아 대상이 비고 exit 0

전부 CI 처럼 전제가 없는 환경을 배려한 기본값이다

그런데 경로 오타로 못 찾은 것과 환경상 없는 것을 구분하지 않으면 그 배려는 상시 스킵이 된다

게이트의 초록불은 "검증했고 문제없다" 와 "검증을 못 했다" 두 뜻을 같은 exit 0 으로 낸다,

읽는 쪽은 앞의 뜻으로만 읽는다

# ✅ 검증 불가 ≠ 검증 통과 — 못 읽으면 차단하거나, 최소한 stderr 로 크게 알린다
try:
    lines = open(path, encoding="utf-8", errors="replace").readlines()
except Exception as exc:
    print(f"🚫 차단: 리뷰 이력을 읽지 못했다({exc.__class__.__name__})", file=sys.stderr)
    sys.exit(2)

커밋 대상은 git status --porcelain 으로 본다

staged·unstaged·untracked 를 한 번에 잡는 유일한 소스다

 

errors="replace" 도 같은 이유다 

transcript 는 하니스가 실시간으로 append 하는 파일이라 한글 3바이트 문자가 잘린 창이 상시 열려 있고,

그 한 글자의 UnicodeDecodeError 가 게이트를 통째로 무성 통과시켰다

게이트가 내 작업을 막으면 게이트를 넓히고 싶어진다

세 번째 방식이 가장 지독하다, 게이트를 고치는 손이 게이트를 연다

리뷰 게이트는 transcript 의 Agent 호출 프롬프트에서 리뷰 마커를 substring 으로 찾는다

정상적으로 리뷰를 2회 돌렸는데 프롬프트가 한국어여서 0/2 로 집계돼 커밋이 막혔다

자연스러운 수정은 마커에 적대적 리뷰·코드 리뷰 를 추가하는 것이다

# ❌ 마커를 넓혀 내 정상 케이스를 통과시켰다
return ('adversarial code reviewer' in blob
        or '적대적 리뷰' in blob or '코드 리뷰' in blob)

이 변경본을 적대적 리뷰에 넘기자 바로 뚫렸다

"적대적 리뷰에서 나온 지적 3건을 반영해줘" 는 리뷰가 아니라 수정 위임인데 마커에 걸린다

리뷰 → 수정 위임 → 수정 위임, 이 흐름은 실제 리뷰 1회인데 게이트는 3회로 센다

더 나쁜 건 이 훅의 차단 메시지 자체가 "유효 코드리뷰가 2회 미만" 이라는 점이다 
막힌 뒤 그 문구를 붙여 넣어 복구를 위임하는 게 가장 자연스러운 동선이고, 두 번 하면 실제 리뷰 0회로 게이트가 열린다

이 훅이 존재하는 이유가 바로 "리뷰 → 수정 → 재리뷰 생략 → 커밋" 고무도장을 막는 것이다

마커를 넓히자 막으려던 구멍이 그대로 다시 열렸다

# ✅ 정규 토큰 하나만 인정한다 — 좁혀서 생기는 실패는 재리뷰(fail-closed), 넓혀서 생기는 실패는 게이트 무력화
return 'adversarial code reviewer' in blob

해결은 훅이 아니라 호출 규약이었다 — 리뷰를 위임할 때 description 에 항상 그 토큰을 넣는다

게이트가 정상 작업을 막을 때 게이트를 느슨하게 고치면, 그 느슨함은 정확히 게이트가 막던 자리에 생긴다

왜 훅은 조용히 죽나 — 훅에는 검증이 없다

세 방식의 공통점은 하나다

훅은 exit code 하나를 내는 프로그램이고, 그 프로그램을 알려진 나쁜 입력으로 돌려보는 사람이 없다

E2E 스위트는 매 실행이 곧 검증이다, 오라클이 깨지면 RED 가 뜬다

훅은 반대다 — 규칙이 죽어도 편집이 막히지 않으니 아무도 아프지 않고, 그래서 아무도 모른다

여기에 한 축이 더 있다, 훅의 상대는 사람이 아니라 에이전트다

에이전트는 막히면 우회한다, 그것도 아주 합리적으로

// 훅이 .page. 를 막으니 지역변수로 받는다 — 규칙 위반은 그대로인데 정규식은 안 걸린다
const p = appPage.page
await p.route('**/x', (r) => r.abort())

즉 게이트는 적대적 사용자를 상시로 두고 있는 코드다

적대적 사용자를 둔 코드에 회귀 검증이 없으면 결과는 정해져 있다

게이트는 세운 시점이 아니라 깨보고 못 깬 시점에 게이트다

훅에 (입력, 기대 판정) 표를 먹인다

그래서 훅에도 검증을 붙였다

구조는 단순하다 — 훅별로 (이름, PreToolUse 페이로드, 기대 exit) 표를 두고,
각 훅을 spawnSync 로 실제 실행해 exit code 를 대조한다

const BLOCK = 2, PASS = 0
const write = (file, content) => ({ tool_input: { file_path: join(ROOT, file), content } })

const stepPom = [
  // 2026-09-05 에 실제로 죽었던 규칙 — 지우지 말 것
  ['XPath 리터럴',        write('steps/t.steps.ts', `await appPage.q("//div[@id='x']");`), BLOCK],
  ['.page.waitForEvent',  write('steps/t.steps.ts', `const d = appPage.page.waitForEvent('download');`), BLOCK],
  // 같은 줄 형태가 핵심 — 두 줄로 나누면 줄머리 앵커를 제거해도 안 잡힌다
  ['같은 줄 URL + 위반',  write('steps/t.steps.ts', `const u = 'https://a.b/c'; await appPage.page.route(u, r => r.abort());`), BLOCK],
  // 통과해야 하는 것 — 오탐은 편집을 막아 마찰이 된다
  ['줄머리 주석 인용',    write('steps/t.steps.ts', `// appPage.page.route 를 쓰지 말 것\nawait appPage.arm();`), PASS],
  ['멤버명 접두',         write('steps/t.steps.ts', `await appPage.pageFileRow('a').click();`), PASS],
]

for (const [name, payload, want] of stepPom) {
  const r = spawnSync('bash', [join(HOOKS, 'check-step-pom.sh')], {
    input: JSON.stringify(payload), encoding: 'utf8', timeout: 15000,
    env: { ...process.env, CLAUDE_PROJECT_DIR: ROOT },
  })
  const got = r.status === 0 ? PASS : r.status
  console.log(`${got === want ? '✓' : '✗'} ${name} — 기대 ${want === BLOCK ? '차단' : '통과'}, 실제 exit=${got}`)
}

표를 짤 때 지킨 원칙이 넷이다

  • 차단 케이스와 통과 케이스를 짝으로 둔다 — 차단만 있으면 훅을 exit 2 한 줄로 바꿔도 초록불이다
  • 죽었던 규칙은 케이스로 박제한다 — XPath 케이스 옆에 "왜 여기 있는지" 를 적어 지워지지 않게 한다
  • 페이로드 형태를 전부 넣는다 — settings.json 이 Edit|Write|MultiEdit 에 훅을 물리는데 표에 Write 만 있으면 Edit 파싱을 지워도 초록불이다
  • fail-closed 가드 자체를 케이스로 넣는다git commit -m "$MSG" 처럼 메시지를 판독할 수 없는 입력은 차단이어야 한다, 이 케이스가 없으면 -m 경로로 위 규칙 전부를 우회할 수 있다

그리고 이 표를 게이트에 다시 물렸다

.claude/hooks/ 나 셀프체크 파일을 건드리는 커밋이면 커밋 훅이 셀프체크를 먼저 돌리고, 하나라도 깨지면 커밋을 막는다

의도한 변경이면 scripts/hook-selftest.mjs 의 기대표를 함께 갱신하세요

오프라인·결정론이라 52케이스에 2초다

죽어 있던 XPath 규칙을 이 표에 태우니 즉시 5건 RED 였다, 손으로 26입력을 대조하던 일이 2초로 줄었다

표도 공격받아야 한다

표를 만든 뒤 그 표를 적대적 리뷰에 넘겼다

훅에 26개 변이를 넣었는데 13개가 초록불로 통과했다

describe.only 를 정규식에서 빼도 통과, feature 파일 분기를 통째로 삭제해도 통과, MultiEdit 파싱을 지워도 통과였다

표에 없는 규칙은 보호받지 못한다, 그 자리를 케이스로 메웠다

그래도 표가 덮는 훅은 셋이고 나머지는 무커버다

그래서 셀프체크가 매 실행 끝에 커버·미커버 목록을 같이 찍게 했다

통과 — 52/52
커버: check-step-pom · block-skip-without-reason · pre-commit-guidelines-check
미커버(훅 단위): remind-code-review-before-commit · check-locator-quality · ...
⚠️ "이 표가 초록불 = 게이트가 온전" 이 아니다

초록불 옆에 미커버 목록이 없으면 그 초록불이 또 하나의 fail-open 이 된다

핵심 정리

죽는 방식 겉모습 원리 막는 법
옆 규칙의 오탐 수정이 이 규칙을 죽임 편집이 잘 통과됨 공통 전처리가 다른 규칙의 입력을 지운다 전처리를 규칙 튜플에 묶고, 죽었던 규칙을 케이스로 박제
전제가 없으면 조용히 통과 게이트 초록불 exit 0 이 "검증 OK" 와 "검증 못 함" 을 같은 값으로 낸다 fail-closed, 불가피하면 stderr 로 크게 알림
내 정상 케이스를 통과시키려 마커를 넓힘 정상 작업이 다시 됨 넓힌 자리가 정확히 게이트가 막던 자리 정규 토큰 하나 + 호출 규약, 훅을 느슨하게 고치지 않음
  • 훅은 exit code 하나를 내는 프로그램이고 적대적 사용자(에이전트)를 상시로 둔다 — 회귀 검증이 없으면 조용히 죽는다
  • 훅마다 (입력, 기대 exit) 표를 두고 실제 실행해 대조한다, 차단·통과를 짝으로, 페이로드 형태를 전부, fail-closed 가드 자체를 케이스로
  • 표는 훅을 건드리는 커밋에서 강제한다 — 규칙을 바꾼 사람이 그 자리에서 깨진 케이스를 본다
  • 표도 공격받는다 — 변이 26개 중 13개가 통과했다, 표에 없는 규칙은 보호받지 못한다
  • 초록불엔 미커버 목록을 같이 찍는다 — "검증됨" 으로 읽히는 초록불은 그 자체가 fail-open 이다
  • 게이트는 세운 시점이 아니라 깨보고 못 깬 시점에 게이트다
반응형