Skip to content

v0.2.20 — 감사 큐 A1~A6

Choose a tag to compare

@xzawed xzawed released this 06 Sep 02:00
· 11 commits to main since this release
ca024ca

v0.2.20 — 감사 큐의 상위 다섯 건

2026-09-05. docs/10-service-audit-queue.mdA1~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.tsdelegate.tsworktree.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가 실패 → dirtyundefineddirty !== 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를 읽고 짚은 것:

  • hasGitFilereadFileSync('<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에, BearerBasic 추가, 종결 마커 없는 PEM은
그 뒤 전부, Stripe·Google 프리픽스 2종.

Grok이 여기서도 하나 더 찾았다: URL 정규식의 사용자 부분이 +여서 사용자명이 없는
redis://:password@cache:6379가 통과했다 — 그게 Redis URL의 표준 형태다. *로 고쳤다.
(같은 패스에서 오탐 후보 3종 — postgres://app@db, 3://412:30@lunch, DATABASE_URL: string
— 은 전부 통과 확인.)

실사용 트래픽으로 검증했다 — 감사가 "한 번도 안 했다"고 지적한 그것

새 규칙을 실제 이력 3,114개 필드에 돌렸다. 결과: 오탐 0건. 즉 이 규칙들은 3천 건이 넘는
진짜 프롬프트·요약을 하나도 망가뜨리지 않는다 — 마스킹이 꺼지게 만드는 실패 모드가 바로 그것이라
이게 확보할 수 있는 가장 강한 증거다.

⚠️ 동시에, 진짜 시크릿을 잡는 능력은 여전히 합성 테스트로만 증명돼 있다. 3,114개 중 새로
걸린 것이 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, 두 번들 재빌드.