Files
multi-agent-mux/.agents/reports/planner-reviewer-codex-01/report-05572944.md
T
GodopuandClaude Opus 5.5 6fd417a7b7 release(v4.9.5): a claude purge removes every path the session owns (Issue #31, PR #59) - archive the review reports, bump the lockstep version, load logroot eagerly
Review loop (--review-only, --all-reviewer, focused on this Mac:
macOS, /usr/bin/python3 3.9.6, bash 3.2): round 1 (@6f4f40a) agy and
grok PASS, claude and codex NOT PASS (a non-uuid id `memory` purged the
auto-memory dir, a linked dir below a root was followed, a NaN
startedAt counted as dead), sent as a PR comment and fixed by the
author in eleven commits; round 2 (@2a8d738) confirmed all three fixed
but 4/4 NOT PASS on one test holder (`sh -c "sleep 120" <uuid>` loses
the uuid on macOS /bin/sh), fixed in de6f3fc; round 3 (@de6f3fc) 4/4
PASS.

Full suite on macOS / Python 3.9.6 @de6f3fc: 1934 passed, 56 skipped,
1 error (the known #55 false positive: agy conversations of another
workspace). The real ~/.claude, /private/tmp/claude-<uid> and herdr
panes unchanged.

Also names lib_py.delegation.logroot in eager.CORE_MODULES (the v4.9.4
review's non-blocking note).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 01:25:15 +09:00

12 KiB
Raw Blame History

PR #59 Mac review — NOT PASS

Job 05572944, role Reviewer. 소스 수정 없이 검토했다. 선택 테스트 총 80 passed, 1 skipped이지만, 추가 탐침에서 삭제 범위 문제 두 건과 live-process 판정 문제 한 건을 확인했다. 병합 전 수정이 필요하다. 국소적인 안전성 수정 및 회귀 테스트로 해결 가능하므로 재계획 에스컬레이션은 하지 않는다.

Review identity

  • loop: loop-20261016T003524Z-a51548
  • base: 26e2c43753169671b600d03274901dbe203988dd
  • head: 6f4f40a4adb2115d5997dffd31fb6473f05cab68
  • tree: f4d9422ad0c1d616b5f37065f3e96b34c28f78d8
  • 검증 트리: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1
  • 범위: base..head 13 commits / 12 files. 아래 파일:라인은 이 head 기준이다.
  • PR #59 본문과 issue #31을 tea의 읽기 명령으로 확인했다.

R1 — [P1] 상위 디렉터리의 심볼릭 링크를 따라 소유 루트 밖을 삭제한다

근거:

  • .agents/skills/lib_py/agents/adapters/claude.py:279–:284: tmp root의 모든 entry를 key로 간주하고 root/key/uuid를 후보에 넣는다. key가 외부 디렉터리를 가리키는 링크인지 검사하지 않는다.
  • 같은 파일 :80–:85: _remove는 최종 path 자체가 링크인지 확인하지만, 상위 key가 링크이면 최종 uuid 디렉터리는 일반 디렉터리로 보인다. 따라서 shutil.rmtree가 링크 너머 실제 디렉터리를 삭제한다.
  • 같은 파일 :266–:270의 projects/file-history/session-env 경로에도 상위 구성요소 경계 검증은 없다.

재현(실제 홈/스크래치패드는 사용하지 않음):

  1. 임시 config/HOME/workspace/tmp를 만들고 새 UUID U를 생성한다.
  2. 임시 소유 루트 밖인 <review-temp>/outside-owned-roots/U/keep.txt를 만든다. 이는 테스트용 sentinel이며 실제 사용자 파일이 아니다.
  3. 임시 <ctmp>/claude-<uid>/linked-workspace를 outside-owned-roots에 연결한다.
  4. _tmp_roots를 이 임시 claude root 하나로 제한하고 ClaudeAgentAdapter.purge_artifacts(U, ctx)를 실행한다. 제한은 실제 /tmp/claude-<uid>에 접근하지 않도록 하는 시험 장치이며 경로 열거/삭제 로직은 변경하지 않는다.

기대: 소유 경계 밖 sentinel은 보존되어야 한다. 최소한 링크를 경유한 후보를 거부하고 residual/경고로 남겨야 한다.

실제:

ANCESTOR_SYMLINK {"victim_exists": false, "link_survives": true,
 "purged": [".../ctmp/claude-501/linked-workspace/<U>"]}

상위 링크 자체는 남고 링크 너머 디렉터리가 삭제됐다. 최종 UUID entry 자체를 symlink로 만든 대조군에서는 target이 살아 있고 링크만 삭제됐다. 즉 leaf symlink 보호만으로 해결되지 않는 문제다.

수정 방향: 명시적으로 설정한 root를 신뢰 경계로 정규화한 뒤, 그 아래 탐색 중 만난 symlink 디렉터리를 따라가지 않는다. tmp의 workspace key 등 중간 경로도 lstat/no-follow로 확인해야 한다. 경계 밖 realpath 후보는 삭제하지 말고 보고한다. 링크 교체 경합까지 고려하면 디렉터리 descriptor와 no-follow 연산으로 안전하게 경로를 여는 방식이 적절하다. leaf-link 대조군과 ancestor-link 탈출 회귀 테스트를 둘 다 추가한다.

R2 — [P2] UUID가 아닌 memory를 세션으로 받아 공유 메모리 디렉터리를 삭제한다

근거:

  • .agents/skills/lib_py/agents/base.py:89–:92: _plausible_id는 일부 경로 문자만 배제한다. memory, 공백, 임의 문자열은 UUID가 아니어도 통과한다.
  • .agents/skills/lib_py/agents/adapters/claude.py:262–:268: 이 검증만으로 projects/<key>/<입력> 디렉터리 전체를 소유 경로로 간주한다.
  • 같은 파일 :287 및 :296: has_artifacts와 purge가 같은 오판을 공유한다. 기존 코드의 단일 <uuid>.jsonl 삭제와 달리 이제 디렉터리 전체 삭제로 이어진다.

재현:

  1. 임시 config의 projects/<workspace_key>/memory/MEMORY.md에 공유 메모리 sentinel을 만든다.
  2. adapter.has_artifacts("memory", ctx)를 호출한다.
  3. adapter.purge_artifacts("memory", ctx)를 호출한다.

기대: Claude 세션 UUID가 아닌 입력을 거부하고 memory를 보존해야 한다.

실제:

MEMORY_ACCEPTED True
NON_UUID {"memory_exists": false,
 "purged": [".../home/.claude/projects/<workspace_key>/memory"]}

현재 malformed-id 테스트는 빈 값, 점, 별표, slash만 다루므로 이 경우를 검출하지 않는다. 정상 UUID를 purge할 때 memory를 보존하는 테스트가 통과하는 것과는 별개다.

한계/구분: 이 호스트에서 실행한 별도의 무수정 CLI purge claude memory 탐침은 일반 conversation_in_use의 프로세스 문자열 검사에 걸려 rc 4로 끝났고 memory를 보존했다. 따라서 CLI가 이 실행에서 memory를 삭제했다고 주장하지 않는다. 위 삭제는 어댑터 호출에서 직접 재현했다. 프로세스 목록에 우연히 memory가 등장하는지 여부는 UUID 검증이나 공유 파일 소유권 보장의 대안이 아니며, CLI target 탐색도 이 잘못된 has_artifacts에 의존한다.

수정 방향: Claude의 디렉터리 삭제 후보를 만들기 전에 Claude 세션 식별자 형식을 검증한다(예: UUID 파싱 및 허용된 표기 검증). 다른 어댑터의 ses_... 식별자까지 전역적으로 금지하지 말고 Claude에 맞는 검증을 적용한다. memory, 공백, 임의 비-UUID 문자열은 has/purge에서 거부하고 실제 공유 memory 파일이 살아 있음을 회귀 테스트로 고정한다.

R3 — [P2] NaN startedAt을 판정 불가로 처리하지 않아 live guard가 열린다

근거:

  • .agents/skills/lib_py/agents/adapters/claude.py:70: float(data["startedAt"])가 "NaN"과 JSON NaN을 예외 없이 변환한다.
  • 같은 파일 :73, :77: NaN은 <= 0도 거짓이고 lstart <= started + 2도 거짓이므로, 살아 있는 PID에 대해 False를 반환한다.
  • 같은 파일 :275: 그 세션 파일은 죽은 프로세스의 소유 경로로 선택된다.
  • .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh:292–:298: in-use rc 0이면 purge gate를 통과한다.

재현:

  1. 실제 Claude 대신 /bin/sleep 60을 띄운다.
  2. 임시 config의 sessions/<sleep-pid>.json에 새 UUID, 해당 live pid, "startedAt": "NaN"을 기록한다.
  3. 모든 Claude/home/tmp 환경을 임시 위치로 지정한 CLI python -m lib_py.agents in-use claude <UUID> <temp-workspace>를 실행한다.

기대: 시작 시각을 비교할 수 없으므로 보수적으로 live=True, CLI rc 4.

실제:

CLI_NAN {"rc": 0, "stdout": "{\"agent\": \"claude\", \"in_use\": false}\n",
 "holder_alive": true, "session_file_exists": true}

직접 함수 탐침에서도 string NaN과 JSON NaN은 둘 다 False였고, _owned_paths에는 이 live PID의 세션 파일이 포함됐다. live fixture를 실제로 purge하지는 않았다.

수정 방향: startedAt 변환 후 math.isfinite 및 유효 범위를 확인하고, 비유한 값이면 True(live/unknown)를 반환한다. 필요하면 bool 등 시각이 아닌 타입도 배제한다. NaN/무한대/결손/파싱 불가 입력과 정상 live/dead/reused PID를 함께 검사한다.

Mac 검증 및 통과한 범위

  • tests/test_issue31_purge_residual.py: 32 passed in 23.89s, rc 0.
  • tests/test_issue42_skill_blocks.py, tests/test_issue55_agent_homes.py: 48 passed, 1 skipped in 1.48s, rc 0. skip은 리뷰 worktree에 untracked .claude/skills가 없는 경우이다.
  • base 26e2c43의 임시 archive 복사본에 새 테스트 파일을 올려 핵심 3건을 실행: test_claude_purge_removes_every_owned_path, test_claude_residual_lists_leftovers_then_nothing, test_purge_cli_refuses_a_live_claude_without_uuid_on_argv 모두 예상대로 실패. 현재 head에서는 통과한다. 전체 32건의 base 비교를 다시 수행한 것은 아니다.
  • 실제 /usr/bin/python3 3.9.6에서 변경 Python 8개 AST 파싱 통과. 변경 shell 2개를 각각 /bin/bash -n으로 검사하여 통과. 실제 Bash 3.2로 stop/in-use fixture 테스트 실행.
  • 실제 BSD /bin/ps -p <sleep-pid> -o lstart= + LC_ALL=C 출력 예: Fri Oct 16 10:18:16 2026. 정상 현재 startedAt은 live=True, 한 시간 이전 startedAt은 PID 재사용으로 판정되어 live=False, 결손은 unknown/live=True. 파싱 불가 ps 출력의 보수적 처리는 신규 테스트로 통과했다. NaN은 위 별도 결함이다.
  • 정상 UUID의 다른 세션/동일 jobs 접두사의 다른 세션/shared history 보존, stale session/key 삭제, 부분 삭제 실패의 소유 증거 유지, CLAUDE_CONFIG_DIR, stop rc 4 및 행 보존/삭제, codex/grok/opencode residual은 새 테스트 범위에서 통과했다.
  • stop SKILL.md의 row 제거 설명과 skill_code_blocks.json 변경은 코드/문서 검사와 일치했다.
  • git diff --check base HEAD 통과. 검증 후 HEAD/tree 일치 및 clean git status 확인.

실제 구조 관찰과 격리 범위

실제 홈에서는 디렉터리 목록/파일 종류/존재 여부만 읽었다. JSON, key, transcript 내용은 열지 않았다. 관찰 당시 ~/.claude에는 projects 16개 디렉터리, file-history 18개, session-env 21개, sessions의 JSON 5개/key 5개가 있었다. jobs의 표본 디렉터리 두 곳에 state.json이 존재했다. /private/tmp/claude-501에는 workspace 디렉터리 5개가 있었고 표본에서 <key>/<session>/scratchpad 구조를 확인했다. /tmp의 realpath는 /private/tmp, 이 Mac의 TMPDIR realpath는 /private/var/folders/.../T였다. 이 관찰은 디렉터리 형태를 확인한 것이며 실제 session JSON 필드 값을 검증했다는 뜻은 아니다.

브리프의 금지 범위를 지키기 위해 테스트는 다음과 같이 격리했다.

  • 지정 env -u 목록(HERDR/WORKSPACE_ROOT/MAM/DELEGATE/CLAUDE_CONFIG_DIR)을 적용하고, HOME/HOME_DIR 및 Claude 프로젝트·tmp 경로를 임시 위치로 지정했다. config 기본값 테스트는 임시 HOME의 .claude를 사용하며 custom config 테스트도 임시 경로만 사용했다.
  • 코드가 명시적 임시 환경과 무관하게 /tmp/claude-<uid>도 탐색하므로, 테스트용 sitecustomize에서 그 실제 fallback root에 대한 isdir만 False로 처리했다. 그 실제 root에 대한 remove/rmdir/rmtree/rename도 방어적으로 차단했다. 이 테스트 보조 파일은 임시 디렉터리에만 있고 제품 소스는 변경하지 않았다.
  • 따라서 tmp root 단위 테스트는 실제 fallback을 제외한 격리 환경에서 통과한 것이다. 실제 fallback의 존재/레이아웃은 위 읽기 관찰로 별도 확인했다.
  • 수동 purge 탐침의 _tmp_roots는 임시 root 하나로 제한했다. CLI 탐침도 동일한 real-fallback 차단을 적용했다. 삭제 sentinel은 전부 리뷰 임시 디렉터리 안의 합성 데이터다.
  • 실제 Claude 프로세스, herdr 서버, monitor는 시작하지 않았다. 실제 홈/실제 scratchpad/실제 agent session에 purge 또는 in-use를 실행하지 않았다. 제어 평면 쓰기는 지시된 lifecycle 이벤트와 본 리포트뿐이다.
  • 모든 pytest 실행에 지정 venv Python, PYTHONDONTWRITEBYTECODE=1, -p no:cacheprovider, 검증 트리 밖 basetemp를 사용했다. creator 트리에서 실행하지 않았다.
  • 전체 스위트는 오케스트레이터의 별도 실행 범위이며 이번 리뷰에서 재실행하지 않았다. 선택 테스트에서는 #55 오탐/teardown 오류가 없었다.

산출물: /tmp/review-05572944-u3lt5wk_/의 pytest.log, docs-home.log, before.log, probes.py, probes.log, cli-probe.py, cli-probe.log, guard/sitecustomize.py. 임시 스크립트는 해당 검증 트리 cwd에서 실행하도록 작성됐으며 모든 파괴적 동작은 자체 임시 환경을 만든 후 수행한다.

[VERDICT: NOT PASS]