v0.2.20 — 감사 큐 A1~A6
v0.2.20 — 감사 큐의 상위 다섯 건
2026-09-05. docs/10-service-audit-queue.md의 A1~A5. 전부 배포 번들을
.claude/tools/mcpcall.mjs로 직접 구동해 재현 → 수정 → 재측정했고, 수치는 전부 그 실행에서
나온 것이다.
이 다섯 건의 공통점: 아무 말도 하지 않는 실패였다. 위험 판정이 조용히 내려가고, 실제 편집이
이력에 남지 않고, 다른 디렉터리에 쓰고도 성공이라 보고하고, 되돌릴 수 없는 삭제가 ok:true로
끝나고, 정리 도구가 정리 대상만 골라 건너뛰었다.
A1. 명시 signals 하나로 위험 게이트가 꺼졌다 (routing.ts)
| 호출 | 0.2.19 | 0.2.20 |
|---|---|---|
{task:"rotate the OAuth client secret and update the auth middleware"} |
HIGH | HIGH |
+ signals:{security:false} |
MEDIUM | HIGH |
+ 전 필드 채운 구조체 |
LOW | HIGH |
v0.2.19는 destructive·production만 막았고 나머지 다섯 HIGH 키는 그대로 껐다. omitempty 없는
Go 구조체나 Python asdict는 매 호출 security:false를 보내므로, 끄려는 의도조차 없이 세션
전체의 키워드 안전망이 꺼졌다.
RISK_RAISING이 이제 모든 위험 상승 키다. 방향은 여전히 한쪽이다 — 명시 true는 텍스트에 없는
위험을 켤 수 있고, LOW 신호는 여전히 자유롭게 끌 수 있다. 자기 저위험 주장을 낮추는 것은
정당하지만, 텍스트가 말하는 위험을 무장해제하는 것은 아니다.
Grok의 반증 시도(격리 worktree, 프로브 3종): 강등 페이로드 20종 → 0건 성공. __proto__·
constructor·배열 signals·타입 혼동(0/"false"/null)은 전부 거부. 명시 true 상승 8종은
전부 유지. Grok이 짚은 것은 반례가 아니라 트레이드오프였다 — "change the password field label,
docs only"는 이제 영원히 HIGH다. 그래서 탈출구를 못으로 박았다: task 없이 signals만 보내면
추론할 텍스트가 없으므로 신호가 그대로 쓰인다. 도구 설명에도 적었다.
곁들여, 이 도구는 additionalProperties: false를 광고하면서 zod가 미지 키를 조용히
버렸다 — meteredBilling(camelCase, 실제 이름은 metered_billing)이 수용·무시되고 종량제
엄격 기준은 적용되지 않았다. .strict()로 광고와 런타임을 일치시켰다.
A2. 프롬프트를 실은 grok_cli가 이력에도 hook에도 안 잡혔다
grok_cli {"args":["-p","Create a file named a2.txt …","--always-approve"]}
→ status ok, a2.txt 생성, 구독 턴 소모
→ ~/.grok-build/history.jsonl: 1790행 → 1790행
/grok:usage·/grok:status는 그 파일만 읽는다. 즉 passthrough 편집은 사용량 보고의 사각지대
였다. PreToolUse matcher도 delegate/plan/verify뿐이라 인증 게이트도 없었다.
게이트는 툴 이름이 아니라 프롬프트를 따라간다. matcher에 grok_cli를 그냥 넣으면
grok --version·grok sessions list가 "로그인 안 됨"으로 막힌다 — 로그인이 안 된 이유를
알아보려고 치는 바로 그 명령들이다. 그래서 hook이 stdin 페이로드의 tool_input.args를 읽어
-p/--single/--prompt-file/--prompt-json일 때만 인증을 요구한다(grok --help 1.0.13로
이 넷이 단일턴 플래그의 전부임을 확인). grok 미설치 deny는 여전히 전부에 적용된다. 페이로드를
읽을 수 없으면 닫히는 쪽으로 실패한다 — runGrokCli에는 서버측 checkAuth가 없어 이 hook이
passthrough의 유일한 인증 게이트다.
기록이 참이려면 파일도 알아야 하므로, 프롬프트 런은 runDelegate와 같은 porcelain 델타를
잰다(읽기 전용 서브커맨드는 git을 아예 부르지 않는다). 이력 행에는 via: "grok_cli"가 붙는다 —
없으면 delegate라는 뜻이라 기존 이력은 마이그레이션이 필요 없다.
재측정: 1790 → 1791, filesChanged: ["a2.txt"], via: "grok_cli", grok_build_usage가 그 행을
보고. grok_cli --version은 행을 늘리지 않음.
Grok이 이 게이트에서도 구멍을 찾았다 — 짧은 플래그 뭉침
첫 판본은 토큰 전체로만 매치했다. 그래서 ["-vp","x"]에서 프롬프트를 못 찾았는데,
grok의 clap은 그걸 -v -p x로 읽는다. 실측(1.0.13):
grok -vp → exit 2, "a value is required for '--single <PROMPT>' but none was supplied"
grok -sp → "--session-id must be a valid UUID (got 'p')" (즉 -s p)
grok -wp → TUI가 떠서 행함 (-w p, worktree 이름 "p")
즉 뭉침은 실재하고, 게이트가 그걸 통과시키면 턴이 게이트 없이 소모된다.
수정하면서 두 질문을 분리했다 — 같은 함수가 답하기엔 서로 다른 질문이라서다:
extractPromptRun(기록용)은 확신할 때만 프롬프트를 말한다.-vp x는 확신한다
(v는 값을 안 받으므로p가 다음 토큰의 주인).-mp x는 아니다 —-m이 값을 받으므로
그건--model p이고 프롬프트가 아예 없다. 이력에 허구를 적느니 아무 말도 안 하는 게 낫다.mayRunTurn(인증 게이트용)은 더 넓다. 해석되지 않는 뭉침에p가 있으면 게이트한다.
과잉 게이트는 로그인한 사용자에게 아무 비용이 없고, 과소 게이트는 확인 없이 쿼터를 쓴다.
곁들여, 이 파서를 prompt-flags.ts(임포트 없는 리프 모듈)로 옮겼다. hook이 이걸 얻으려고
grok-cli.ts → delegate.ts → worktree.ts를 통째로 인라인해 dist/hook.js가
6,612 → 9,660바이트가 됐었다. 지금은 8,587바이트이고 두 파일 다 들어 있지 않다.
A3. resume이 caller의 cwd를 조용히 무시했다 (delegate.ts)
delegate {prompt:"Create b.txt …", cwd:<dirB>} → completed, b.txt in dirB, sessionId S
delegate {prompt:"Create a.txt …", cwd:<dirA>, resume:S} → completed, filesChanged: []
→ a.txt는 dirB에 생성됨
grok의 --resume이 --cwd를 덮어쓴다 — 세션은 자기가 태어난 디렉터리에 묶인다(계약 §12).
응답에도 이력에도 신호가 없었고, 이력의 cwd는 dirA로 잘못 남았다.
grok은 세션 위치를 파일시스템에 드러낸다(<grokHome>/sessions/<url-encoded cwd>/<id>). resume이
주어지면 spawn 전에 소유 디렉터리를 찾아, 다르면 그쪽 델타도 함께 잰다. 결과에 resumedCwd와
경고가 붙고 filesChanged는 두 디렉터리의 합집합이다. --continue는 사전에 세션을 특정할 수 없어
경고와 resumedCwd만 붙고 파일 주장은 하지 않는다.
이 조회는 이 레포가 소유하지 않은 레이아웃을 읽고, grok은 스스로 업데이트된다. 그래서 못 찾으면
아무 주장도 하지 않고 이전과 동일하게 지나간다 — 틀린 주장보다 무주장이 낫다.
재측정: filesChanged: ["a2.txt"], resumedCwd가 dirB를 지목, 메시지가 덮어쓰기를 설명.
A4. worktree remove가 미적용 산출물을 복구 불가하게 지웠다
--force가 무조건이었다. 이 플러그인은 커밋하지 않으므로 worktree의 grok 산출물은 정의상
미커밋이다 — apply 전에 remove하면 blob·reflog·브랜치 어디에도 남지 않고, 응답은 ok:true뿐이었다.
같은 데이터에 대해 prune은 "깨끗하다고 증명되지 않으면 안 지운다"였다. 두 명령, 반대 기본값.
이제 먼저 상태를 재고, 모르는 것은 dirty로 친다(prune이 이미 문서화한 이유 그대로 — 답 없는
상태를 "안전"으로 읽는 것이 작업물이 사라지는 경로다). 거부 메시지는 위태로운 파일을 이름으로
말한다. force: true는 호출자가 소리 내어 말하는 것이다.
실측: 감사가 남긴 SCAManager 소유 dirty 트리에 remove를 걸면 ok:false와 함께
src/analyzer/io/tools/throwaway_axis_a.py를 지목하며 거부한다. 트리는 그대로 남는다.
A5. prune이 진짜 고아를 절대 수집하지 못했다
소유 repo가 삭제되면 git -C <tree> status가 실패 → dirty는 undefined → dirty !== false
가드가 skippedDirty로 보낸다. prune을 돌리는 유일한 이유가 매번 후보로 뜨고 매번 건너뛰어졌다.
반대로 실제로 rmSync되던 유일한 경로는 baseDir 아래 우연히 놓인 독립 repo의 커밋된 이력이었다.
고아를 이제 분류로 찾는다: git이 답하지 않고, .git 파일이 없거나 그 파일이 가리키는 repo가
사라졌으면 고아다. 소유 repo가 살아 있는데 git이 답하지 않는 트리는 여전히 "판단 불가"로 보호된다
(2026-09-02 사고의 방어를 조용히 되돌리지 않기 위해). 그리고 removedOrphan은 이 래퍼가 지은
이름(grok-<base36>-<base36>)에만 적용된다 — 감사가 글로벌 base dir에서 오너의 체크아웃을 발견한
적이 있고, git이 모른다는 것이 우리 것이라는 뜻은 아니다. 실패한 remove를 rmSync로 흘려보내던
경로는 제거했다.
실측: 진짜 고아를 하나 만들어(worktree 생성 → 소유 repo 삭제) dry run을 돌리면
orphan: true·dirty: undefined로 분류되고 "apply하면 삭제됩니다"라고 예고한다. 이전에는
skippedDirty였다. 이 머신에 남아 있던 9~10개는 전부 소유 repo가 살아 있어 고아가 아니다 —
안 지우는 게 맞다.
Grok이 이 수정의 첫 판본에서 구멍을 찾았다
A5의 첫 판본은 파괴 경로를 좁혔을 뿐 닫지 못했다. Grok이 pruneGrokWorktrees를 읽고 짚은 것:
hasGitFile은readFileSync('<tree>/.git')에서 왔는데, 그건.git이 디렉터리면
EISDIR로 던진다 — 즉 평범한 체크아웃이 "git이 모르는 디렉터리"와 구별되지 않았다./^grok-[0-9a-z]+-[0-9a-z]+$/는grok-build-plugin·grok-my-project에도 매치한다 —
사람이 충분히 지을 법한 이름이다.
둘이 겹치면: PATH에 git이 없을 때 모든 status 프로브가 실패하므로, base dir에 있던 진짜
저장소들이 한꺼번에 고아로 분류돼 삭제된다. A5가 끝내려던 바로 그 파괴가 좁은 모양으로
되살아난 것이다. 둘 다 실측으로 확인했다(readFileSync의 .git 디렉터리 → EISDIR;
정규식 → 네 이름 전부 매치).
수정: .git 엔트리의 종류를 직접 본다(file/dir/none) — .git 디렉터리는
저장소다, 끝. 그리고 이름 패턴을 worktreeName()이 실제로 만드는 모양으로 좁혔다
(/^grok-[0-9a-z]{7,10}-[0-9a-z]{1,8}$/ — 가운데는 base36 밀리초 타임스탬프라 이 소프트웨어가
돌 수 있는 모든 날짜에서 7~10자다). grok-build-plugin·grok-my-project는 이제 거부된다.
재측정: 진짜 고아는 여전히 orphan: true로 수집 예고되고, base dir에 놓인 커밋 이력이 있는
진짜 저장소(wrapper 모양 이름)는 orphan: false로 보호된다.
A6. promptPreview 마스킹 구멍 (history.ts)
프롬프트에 붙여넣은 시크릿은 ~/.grok-build/history.jsonl에 200자 미리보기로 남고,
grok_build_usage.recent[]·grok_build_status.lastSession이 그걸 대시보드 호출마다 Claude
컨텍스트로 되돌려준다. 출하 중인 정규식에 8가지 실제 형태를 넣어 봤더니 8/8 전부 원문으로
통과했다:
DATABASE_URL=postgres://app:PW@db.internal:5432/prod LEAKED
DB_URL=mysql://admin:PW@10.0.0.5/app LEAKED
CONNECTION_STRING: mongodb+srv://root:PW@cluster0… LEAKED
https://user:ghp_…@github.com/org/repo.git LEAKED
Authorization: Basic YWRtaW46… LEAKED
sk_live_51Qx… LEAKED
AIzaSyD-… LEAKED
-----BEGIN RSA PRIVATE KEY----- (종결 마커 없음) LEAKED
마지막 것이 특히 나쁘다 — 미리보기는 200자에서 잘리므로 종결 마커가 없는 쪽이 오히려
평범한 경우인데, 정규식이 -----END-----를 요구해서 흔한 경우가 새는 쪽이었다.
수정: URL 자격증명(비밀번호만 치환, 호스트·경로는 남긴다 — 행 전체를 지우는 마스킹은 결국
꺼진다), DATABASE_URL류 키 8개를 NAMED_KEYS에, Bearer에 Basic 추가, 종결 마커 없는 PEM은
그 뒤 전부, Stripe·Google 프리픽스 2종.
Grok이 여기서도 하나 더 찾았다: URL 정규식의 사용자 부분이 +여서 사용자명이 없는
redis://:password@cache:6379가 통과했다 — 그게 Redis URL의 표준 형태다. *로 고쳤다.
(같은 패스에서 오탐 후보 3종 — postgres://app@db, 3://4와 12:30@lunch, DATABASE_URL: string
— 은 전부 통과 확인.)
실사용 트래픽으로 검증했다 — 감사가 "한 번도 안 했다"고 지적한 그것
새 규칙을 실제 이력 3,114개 필드에 돌렸다. 결과: 오탐 0건. 즉 이 규칙들은 3천 건이 넘는
진짜 프롬프트·요약을 하나도 망가뜨리지 않는다 — 마스킹이 꺼지게 만드는 실패 모드가 바로 그것이라
이게 확보할 수 있는 가장 강한 증거다.
걸린 것이 0건이라는 말은 이 사용자의 이력에 그런 형태가 없다는 뜻이기도 하다. 유일한
<redacted> 1건은 감사가 직접 주입한 시험 행(f:/tmp/grok-service-audit/…)이고 —
docs/10 D가 3행을 지웠다고 적었는데 1행이 남아 있다 — 즉 실사용에서 이 규칙이 발동한 적은
아직 없다. 그건 실제 유출이 일어나야만 닫히는 종류의 검증이라 열어 둔다.
남은 것
docs/10에 14건(A7~A20)이 열려 있다. 다음은 A7 — PreToolUse hook의 subscription deny 분기가
출하 상태에서 무장되지 않는다.
유닛 테스트 460 (+40), tsc --noEmit clean, 두 번들 재빌드.