Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/report-63919c5d.md
T
GodopuandClaude Opus 5.5 50c8da8269 release(v4.9.1): tests no longer reach a real herdr server - archive the review reports and bump the lockstep version
Review loop (--review-only, --all-reviewer): round 1 (@325b056) 4/4 PASS,
round 2 (@db7f4ca, HERDR_SESSION) 4/4 PASS. Full suite at 325b056 on
macOS / Python 3.9 / bash 3.2 / herdr 0.9.3 from an agent pane:
1845 passed / 0 failed / 56 skipped (18 test_deploy_freshness skips: no
nats-docker submodule in the fresh worktree); the panes of the real
servers (default, multi-agent-mux) and ~/.claude/projects were unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-13 10:33:04 +09:00

10 KiB

테스트의 실제 herdr 서버 누출 수정 리뷰 (job 63919c5d)

  • 리뷰어: planner-reviewer-claude-01 (Reviewer)
  • 리뷰 identity: loop-20261013T001819Z-131fa6 / base 8b0b4a07 .. head 325b0560 (커밋 2개: 525df2b 수정, 325b056 s11 기준 이동 / 4 파일) / tree 7905896f
  • 검증 경로: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261013T001819Z-131fa6/1 — HEAD·tree 가 identity 와 일치, git status --porcelain 0 줄(시작·종료 시), .mam 디렉터리도 생기지 않음. 본 체크아웃 HEAD 8b0b4a0(main) 변동 없음.
  • 실행 안전: 내 pane 은 HERDR_SOCKET_PATH·HERDR_PANE_ID·HERDR_TAB_ID·HERDR_WORKSPACE_ID·HERDR_SESSION_NAME·HERDR_SESSION·HERDR_CONFIG_PATH·HERDR_BIN_PATH·HERDR_ENV 를 물려받고 있어, 모든 테스트·탐침은 env -i(PATH/HOME/LANG/TMPDIR 만)로 돌려 브리프의 -u 목록을 포함한 전부를 비웠다. basetemp 는 세션 scratchpad 아래(경로에 "shim" 없음). 실제 바이너리를 부른 것은 일회용 HOME 에서의 status server(읽기 전용, 서버 미기동)뿐이다. Python 은 본 체크아웃 .venv(3.9.6, paho 2.1.0). .mam.env 는 읽지 않았다.

1. 원인 판정이 맞는가

원인 1 (조상 *-shim 이 mock 을 가림). base 의 _is_shim_path 는 *-shim/* 를 "/$1/" 전체에 대해 매칭해 조상 디렉터리까지 걸렀고(lib.sh base), 생성 shim 의 _resolve_real_herdr 도 [[ "$dir" != *"-shim"* ]] 로 PATH 항목 문자열 어디든 걸렀다. basetemp pytest-pr56-shim 아래 mock bin/ 은 둘 다에서 건너뛰어졌고 다음 후보인 /opt/homebrew/bin/herdr 가 "real" 이 된다. 수정 전 lib.sh 로 되돌린 scratch 복사본에서 L1 이 정확히 그 상태로 실패했다: _resolve_real_herdr_path 가 …/pytest-x-shim/case0/bin/herdr 대신 다음 PATH 항목의 디코이 …/decoy/bin/herdr 를 골랐다(assert '/…/decoy/bin/herdr' == '/…/case0/bin/herdr'). 판정 맞음.

원인 2 (pane 의 HERDR_SOCKET_PATH). 실제 herdr 0.9.3 바이너리를 일회용 HOME 에서 status server 로 읽기만 해 확인했다:

환경 바이너리가 가리킨 소켓
HERDR_SOCKET_PATH=/nonexistent/s.sock + --session t-zz /nonexistent/s.sock
HERDR_SOCKET_PATH=/nonexistent/s.sock + HERDR_SESSION=t-zz /nonexistent/s.sock
HERDR_SESSION=t-zz 만 $HOME/.config/herdr/sessions/t-zz/herdr.sock (가짜 HOME 아래)
HERDR_SOCKET_PATH 만 /nonexistent/s.sock

즉 HERDR_SOCKET_PATH 는 --session 보다도 우선한다. shim 이 항상 --session <샌드박스 서버> 를 붙여도 이 변수가 남아 있으면 실제 서버로 간다 — 원인 1 로 실제 바이너리가 선택되는 순간 샌드박스 HOME 은 아무 보호가 되지 못했다는 오케스트레이터의 실측과 일치한다. 두 원인은 독립적이고 둘 다 막아야 한다(1 만 고치면 pane 변수 누출 시 다른 경로의 실제 호출이 여전히 실제 서버로, 2 만 고치면 숨은 mock 대신 실제 바이너리가 가짜 HOME 에 서버를 띄움). 판정 맞음.

2. 수정 내용 검토

  • lib.sh:113-124 _is_shim_path: 첫 case 에서 *-shim/* 만 빠지고 .mam/shim·$_HERDR_SHIM_DIR_PATTERN(/multi-agent-herdr-shim/)·$_HERDR_SKILLS_BIN_PATTERN(/.agents/skills/.bin) 은 그대로. *-shim 은 "${1%/}" 즉 인자 자신의 끝에만 적용. lib.sh:130-136 _resolve_real_herdr_path 는 PATH 항목(_is_shim_path "$dir")과 후보 파일의 canonical dirname 둘 다 검사한다 — L1 의 mock 은 tmp/bin/herdr 로의 symlink 라 canonical dirname 은 tmp/bin 이고, 선택 결과는 PATH 항목 쪽 경로($dir/herdr)다. 기존 래퍼 디렉터리 제외 여부: .mam/shim → 첫 case(부분 문자열, 조상 포함) 유지; multi-agent-herdr-shim → 패턴 상수 유지("/…/multi-agent-herdr-shim/…/"); .agents/skills/.bin → 패턴 유지(이 저장소에는 그 디렉터리가 없다); 테스트의 herdr-shim(L1b)·py-shim·ps-shim(test_issue17_item4_decisions.py:208, test_issue21_acceptance.py:283, test_issue30_stall_watch_stop.py:95) 은 디렉터리 자신의 이름이 *-shim 이라 계속 제외.
  • 생성 shim(lib.sh:173-178): [[ "${dir%/}" != *-shim ]]. .mam/shim 부분 문자열 검사는 그대로.
  • tests/conftest.py:293-298 mam_sandbox: HERDR_SOCKET_PATH·HERDR_PANE_ID·HERDR_TAB_ID 제거. 기존의 HERDR_SESSION_NAME·HERDR_SERVER_NAME·HERDR_WORKSPACE_ID·HERDR_CONFIG_PATH·XDG_CONFIG_HOME 제거와 합쳐 pane 이 내보내는 라우팅 변수 중 실제 서버에 닿을 수 있는 것은 모두 빠진다(아래 비차단 메모의 HERDR_SESSION 참조).
  • bash 3.2: ${1%/}·${dir%/}·case 뿐, bash -n 통과. Python 3.9 컴파일 통과.
  • s11 기준 커밋 525df2b: 생성 shim heredoc 이 바뀌었으므로 기준을 그 커밋으로 옮긴 것은 #45 때와 같은 방식이고, 검증 경로에서 s11 PASSED. 525df2b 는 이 브랜치의 첫 커밋으로 검증 경로에 존재한다.

3. 충분한가 — 다른 경로로 실제 서버에 닿는 테스트

  • mock_herdr(conftest.py:310)·mock_agents(:1412) 는 mam_sandbox 에 의존하므로 mock 을 PATH 에 두는 테스트는 모두 샌드박스 환경(라우팅 변수 제거, HOME 교체)이다.
  • mam_sandbox 없이 herdr 를 흉내 내는 파일은 test_issue14_delivery_ack.py·test_issue18_liveness_contract.py 둘(bash 함수 _sks_herdr 대체, HOME=tmp). 이 둘이 herdr 바이너리를 한 번이라도 부르는지 보려고 호출을 기록하는 디코이 herdr 를 PATH 맨 앞에 두고 두 파일을 돌렸다: 43 passed, 디코이 호출 0 건(로그 파일이 생기지 않음). 즉 이 둘은 라우팅 변수가 남아도 바이너리를 실행하지 않는다. (이 실행의 1 error 는 세션 teardown 의 #55 agent-home 가드가 ~/.gemini/antigravity-cli/conversations/f9bf3d55-….db-shm/.db-wal 을 잡은 것으로, 브리프가 예고한 다른 워크스페이스의 실행 중 agy 대화 WAL/SHM 이다. 테스트 산출물이 아니므로 판정에 쓰지 않고 보고만 한다.)
  • test_issue6_fail_closed.py·test_issue6_turn_handshake.py 는 mock_herdr 를 쓰므로 샌드박스다.
  • 실제 바이너리가 아는 라우팅 변수는 HERDR_SOCKET_PATH(최우선)와 HERDR_SESSION(HOME 상대 경로)뿐임을 위 표로 확인했다. HERDR_SESSION_NAME 은 바이너리가 모르고 shim 만 --session 으로 바꾼다(어제 리뷰에서 확인). 따라서 HERDR_SOCKET_PATH 제거 + 가짜 HOME 이면 shim 경로든 직접 호출이든 실제 서버에는 닿지 않는다.

4. 테스트 결과

항목 결과
head: test_herdr_real_server_leak.py + test_issue23_24_startup_path.py + test_herdr_shim_contract.py + test_issue17_workspace_root_isolation.py + test_issue9_server_env.py + test_issue45_herdr_config.py 130 passed, 34 skipped(skip 은 모두 needs tomllib (Python 3.11+) 류의 알려진 3.9 skip). L1·L1b·L2·s11 은 모두 PASSED 로 실행됨
수정 전 lib.sh(8b0b4a0) + 새 테스트 L1 실패(디코이 선택), L1b 통과(자기 이름 *-shim 은 전후 모두 제외)
수정 전 conftest(8b0b4a0) + 새 테스트 L2 실패: ['HERDR_PANE_ID', 'HERDR_SOCKET_PATH', 'HERDR_TAB_ID'] 가 남음
디코이 sweep(test_issue14, test_issue18) 43 passed, 디코이 호출 0

수정 전 재현에는 실제 herdr 가 실행될 수 있는 구성을 쓰지 않았다(테스트 설계대로 디코이가 PATH 에서 실제 바이너리보다 앞서고, env -i 로 소켓 변수도 없음).

5. 공유 제어 평면에 주는 영향

  • 생성 shim 은 lib.sh 를 source 하는 모든 실행에서 <워크스페이스>/.mam/shim/herdr 로 다시 쓰이므로, 머지 뒤 실제 제어 평면의 shim 도 두 줄이 바뀐다. 바뀌는 것은 "조상 이름에 -shim 이 든 PATH 항목을 더 이상 건너뛰지 않음" 뿐이고, .mam/shim 과 자기 이름이 *-shim 인 디렉터리는 계속 건너뛴다. 내 pane 의 PATH 에서 -shim 을 포함하는 항목은 ~/.cmuxterm/cmux-cli-shims/<uuid> 하나인데(herdr 없음, amp claude codex hermes pi 만) 전후 어느 규칙에도 해당하지 않아 영향 없다. 본 체크아웃의 .mam/shim/herdr 는 이 리뷰 중 건드리지 않았다(아직 base 내용).
  • 비차단 메모 1: README(README.md:102, README.ko.md:84)가 설명하는 레거시 레이아웃 /tmp/multi-agent-herdr-shim/<server>/herdr 는 디렉터리 자신의 이름이 서버명이라, 생성 shim 의 새 규칙으로는 제외되지 않는다(lib.sh 의 _is_shim_path 는 패턴 상수로 계속 제외). 현재 코드는 그 레이아웃을 만들지 않고 이 호스트에도 없으므로 동작 영향은 없지만, 생성 shim 에도 *"/multi-agent-herdr-shim/"* 를 두거나 README 를 현재 레이아웃(.mam/shim)으로 고치면 깔끔하다.
  • 비차단 메모 2: mam_sandbox 는 HERDR_SESSION(바이너리가 아는 변수, 내 pane 도 내보냄)을 지우지 않는다. 가짜 HOME 아래 경로로 풀리므로 실제 서버 누출은 아니지만, lib.sh:1425 _herdr_caller_server 와 pane_lock.caller_server 가 이 값을 "호출자 서버" 로 읽어 pane 안/밖에서 테스트 분기가 달라질 수 있다. 결정성을 위해 함께 지우는 것을 권한다(이 PR 의 범위 밖).

6. 잔류물

  • scratch 복사본·basetemp·디코이·로그는 모두 세션 scratchpad 아래. 일회용 HOME /tmp/j639-h 제거(서버를 띄운 적 없음). 실제 .mam·.mam.env·모니터·실제 서버(multi-agent-mux, default)·~/.config/herdr·에이전트 홈은 건드리지 않았다.

결론

두 원인 판정은 코드와 실측(특히 HERDR_SOCKET_PATH 가 --session 보다 우선)으로 확인되고, 수정은 각각을 막으며 회귀 테스트는 수정 전 코드에서 정확히 그 이유로 실패한다. 기존 래퍼 디렉터리 제외는 유지되고, mock 없이 돌아가는 두 테스트 파일은 herdr 바이너리를 아예 부르지 않는다.

[VERDICT: PASS]