diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-1876c7c1.md b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-1876c7c1.md new file mode 100644 index 0000000..81b8bf6 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-1876c7c1.md @@ -0,0 +1,483 @@ +# B-3 구현 계획서 **Rev.2** — herdr 프리플라이트가 실제 바이너리를 검사하도록 수정 + +- **Job**: `9840cee7` / Role: **Planner** (Rev.1 = Job `1876c7c1`) +- **대상**: IMPROVEMENTS.md **B-3** — "`command -v herdr` 프리플라이트 무력화" +- **기준 커밋**: `924e77e` (clean tree) +- **반영**: Creator `agy` Challenge Report (Job `a56c0c5f`) — 심링크 맹점 1건 +- **검증 상태**: Rev.1 + Rev.2 수정 전량을 `git archive HEAD` 클론 3벌(HEAD / Rev.1 / Rev.2)에 **실제 구현하여 재측정 완료**. + +--- + +## 0. Rev.1 → Rev.2 변경 요지 + +| 이의 | 판정 | 근거 | +|---|---|---| +| **제시된 기제** — macOS `/var`↔`/private/var`, `/tmp`↔`/private/tmp`, `~/projects`→`/Volumes/...` 심링크로 인해 `.mam/shim` **디렉터리** 패턴 매칭이 우회됨 | **기각 (REFUTED)** | 인용된 심링크 형태 **전부 이미 SKIP** 된다. 구조적 이유: 심링크 해석은 경로의 **접두부**를 바꾸는데 패턴은 **접미부** `/.mam/shim/` 를 본다. 같은 디렉터리의 어떤 표기든 접미부는 동일하므로 정규화가 결과를 바꿀 수 없다. | +| **제시된 처방** — PATH 항목마다 `cd -P`/`pwd -P` 로 canonical 비교 | **기각 (REJECTED)** | 실제로 존재하는 유일한 상대경로 구멍(`.mam/shim`)조차 **cwd 가 `WORKSPACE_ROOT` 와 같을 때만** 닫는다. 다르면 상대 `cd -P` 가 실패해 구멍이 그대로 열린다(실측). 게다가 PATH 항목마다 서브셸을 띄운다. | +| **결론** — "심링크로 shim 가드를 우회할 수 있다" | **✅ 인용 (CORRECT)** | **맞다.** 다만 기제가 디렉터리가 아니라 **파일**이다. 평범한 bin 디렉터리 안의 심링크(`/usr/local/bin/herdr → /.mam/shim/herdr`)는 **어떤 디렉터리명 패턴으로도 걸러낼 수 없고**, Rev.1 이 이를 실제 바이너리로 오판한다(실측 확인). | +| **(부수) 자체 발견** | 수정 | 상대 PATH 항목 `.mam/shim`(선행 `/` 나 `./` 없음)이 Rev.1 패턴을 통과한다. | + +**총평**: `agy` 의 **결론은 옳았고 분석은 틀렸다.** 지적한 세 가지 심링크 시나리오는 모두 이미 처리되고 있었으며 제안 코드는 그들이 상정한 문제도, 실재하는 문제도 해결하지 못한다. 그러나 "심링크로 우회 가능"이라는 **판단 자체는 참**이었고, 그 참인 판단을 검증하는 과정에서 **Rev.1 의 실제 결함 2건**을 찾아냈다. 이 지적이 없었다면 두 구멍은 남았을 것이다. + +--- + +## 1. 요약 + +B-3 은 실재하며 **추적 항목이 서술한 것보다 나쁘다.** + +- 원인이 1개가 아니라 **독립적인 우회 경로 2개**다. +- 결과가 "무조건 통과"가 아니라 **행(hang)** 이다 — 실측에서 120초 후 SIGKILL 로만 종료됐다. + +`command -v` 대신 `type -P` 를 쓰는 자연스러운 수정은 **작동하지 않는다.** `type -P herdr` 는 `.mam/shim/herdr` 래퍼를 찾아내기 때문이다. HEAD 의 프리플라이트 줄에는 이미 `type -P` 가 있으나 `||` 체인 앞단에서 단락되어 아무 일도 하지 않는다. + +**해결**: lib.sh 의 스텁 `_resolve_real_herdr_path()` 를 구현하고 `has_real_herdr()` 술어를 노출한다. 🆕 Rev.2 에서 **문자열 패턴만으로는 불충분**함이 드러나, 후보 파일에 한해 심링크를 해석하는 단계를 추가했다(§2-a). + +--- + +## 2. 현황 측정 + +### 2-a. 🆕 심링크 우회는 **파일 수준**에서만 발생한다 (Rev.2 핵심) + +디렉터리 표기 12종을 Rev.1 패턴에 통과시킨 실측: + +``` + /Users/x/proj/.mam/shim SKIP + /var/folders/q_/abc/T/proj/.mam/shim SKIP ← agy 시나리오 1 + /private/var/folders/q_/abc/T/proj/.mam/shim SKIP ← agy 시나리오 1 (정규화 표기) + /tmp/proj/.mam/shim SKIP ← agy 시나리오 1 + /private/tmp/proj/.mam/shim SKIP ← agy 시나리오 1 (정규화 표기) + /Volumes/Data/mam/.mam/shim SKIP ← agy 시나리오 2 + ./.mam/shim SKIP ← agy 시나리오 3 (상대경로) + .mam/shim KEEP ←── 실재하는 구멍 (agy 미지적) + /usr/local/bin KEEP (정상) +``` + +**이의제기가 든 심링크 사례는 전부 이미 막혀 있다.** 이유는 우연이 아니다 — 심링크 해석은 경로 **접두부**를 바꾸고, 패턴은 **접미부** `/.mam/shim/` 를 검사한다. 같은 디렉터리를 가리키는 어떤 표기든 `.../.mam/shim` 으로 끝나므로 양쪽 표기가 동일하게 매칭된다. **canonical 화는 이 판정을 바꿀 수 없다.** + +그러나 **파일 수준 심링크는 다르다.** 평범한 bin 디렉터리 안의 링크는 디렉터리명이 아무 패턴에도 걸리지 않는다: + +``` +$ ln -s /.mam/shim/herdr /symbin/herdr +$ PATH=/symbin:... ; has_real_herdr + Rev.1 -> ACCEPTED <-- shim 래퍼를 진짜 herdr 로 오판 + resolved: /symbin/herdr + (readlink -> /.mam/shim/herdr, inside .mam/shim: True) +``` + +**이것이 B-3 결함의 재발 경로다.** 오판된 "실제 herdr" 는 shim 이므로 §2-d 의 행(hang)이 그대로 재현된다. + +### 2-b. 우회 경로 2개 (Rev.1 에서 확립, 변경 없음) + +herdr 가 전혀 없는 PATH 에서 lib.sh 를 source 한 뒤: +``` +command -v herdr -> [herdr] rc=0 ← 우회 ①: lib.sh:498 herdr() 함수 +type -t herdr -> [function] +type -P herdr -> [/.mam/shim/herdr] rc=0 ← 우회 ②: shim 래퍼 +``` +- 우회 ①: `lib.sh:498` 이 `herdr()` 셸 함수를 정의. `command -v` 는 함수를 매칭. +- 우회 ②: `lib.sh:1742` 가 **source 시점에** `_init_herdr_isolation` 을 호출 → `.mam/shim` 을 PATH 맨 앞에 prepend. + +### 2-c. HEAD 의 프리플라이트는 "고쳐진 척" 되어 있다 + +```bash +create_session.sh:84 +command -v herdr >/dev/null || type -P herdr >/dev/null || { echo "ERROR: herdr not installed" >&2; exit 1; } +``` +`git blame`: `ea36e816`. `type -P` 가 `||` 체인 **뒤쪽**이라 앞의 `command -v` 가 rc=0 을 내면 **평가조차 되지 않는다.** 평가되더라도 우회 ②로 통과한다. 실측: `PREFLIGHT: PASS <-- binary absent, yet passes`. + +### 2-d. 실제 결과는 "통과"가 아니라 "행(hang)" + +claude 는 있고 herdr 만 없는 상태의 HEAD 실측: +``` +subprocess.TimeoutExpired: ... timed out after 120 seconds + ← SIGKILL 로만 종료 +``` +프리플라이트 통과 후 shim 을 통해 세션을 만들려다 블록된다. shim 내부 `_resolve_real_herdr` 는 실제 herdr 를 못 찾으면 리터럴 `"herdr"` 로 폴백하고 그 지점에서 멈춘다. **사용자 영향은 "잘못된 성공"이 아니라 "원인 불명의 멈춤"이다.** + +### 2-e. 올바른 스캔 로직은 이미 저장소 안에 있다 + +`lib.sh:53-56` 의 `_resolve_real_herdr_path()` 는 스캔을 전혀 하지 않는 **스텁**이다(C-4 가 미사용 심볼로 추적 중). 반면 **shim 스크립트 내부**에는 `.mam/shim` / `*-shim*` 을 건너뛰는 스캔이 이미 구현되어 있다 → 승격하면 된다. (단 Rev.2 는 여기에 파일 수준 해석을 **추가**한다.) + +### 2-f. 기존 테스트가 못 잡은 이유 + +`conftest.py` 의 `mock_herdr` 픽스처는 항상 실행 가능한 mock 바이너리를 PATH 에 올린다. 즉 **"herdr 가 없는" 경로가 한 번도 실행되지 않았다.** + +--- + +## 3. 변경 명세 + +### F1 — `.agents/skills/lib.sh` (핵심) 🆕 Rev.2 개정 + +스텁을 3개 함수로 교체한다. + +```bash +# Canonicalise a file path: follow symlinks (bounded), then resolve the parent +# directory with `cd -P`. Hand-rolled rather than shelling out to `readlink -f` +# or `realpath`: both exist on this host and on recent macOS, but neither is +# guaranteed on older macOS or on minimal container images, and this runs inside +# a pre-flight that must not itself depend on an optional tool. +_canonical_file() { + local p="$1" t d b i=0 + while [ -L "$p" ] && [ "$i" -lt 40 ]; do + t="$(readlink "$p" 2>/dev/null)" || break + case "$t" in + /*) p="$t" ;; + *) p="$(dirname "$p")/$t" ;; + esac + i=$((i + 1)) + done + d="$(cd -P "$(dirname "$p")" 2>/dev/null && pwd -P)" || return 1 + b="$(basename "$p")" + printf '%s/%s\n' "$d" "$b" +} + +# True if a path lies inside any wrapper/shim location. +# The argument is wrapped as "/$1/" so the leading-slash patterns match even a +# bare relative entry such as `.mam/shim` (an absolute path just gains a +# harmless leading `//`). +_is_shim_path() { + case "/$1/" in + *"/.mam/shim/"*|*-shim/*|*"$_HERDR_SHIM_DIR_PATTERN"*|*"$_HERDR_SKILLS_BIN_PATTERN"/*) + return 0 ;; + esac + return 1 +} + +# Resolve the REAL herdr binary, skipping every wrapper/shim location. +# Prints the absolute path on stdout; returns 1 when only wrappers exist. +# +# B-3: neither of the obvious one-liners can answer "is herdr installed?": +# * `command -v herdr` matches the herdr() shell FUNCTION defined below. +# * `type -P herdr` matches $WORKSPACE_ROOT/.mam/shim/herdr, because +# _init_herdr_isolation prepends that dir to PATH when this file is sourced. +_resolve_real_herdr_path() { + local dir cand save_ifs="$IFS" real_path="" + IFS=: + for dir in $PATH; do + [ -n "$dir" ] || continue + # Cheap string reject first: skips wrapper dirs without touching the disk. + _is_shim_path "$dir" && continue + [ -x "$dir/herdr" ] || continue + # Only now — for an actual candidate, so at most once per call — resolve + # symlinks. A link like /usr/local/bin/herdr -> /.mam/shim/herdr sits in + # a directory no string pattern can reject; only the resolved target tells + # the truth. Falls back to the literal path if canonicalisation fails. + cand="$(_canonical_file "$dir/herdr" 2>/dev/null)" || cand="$dir/herdr" + [ -n "$cand" ] || cand="$dir/herdr" + _is_shim_path "$cand" && continue + real_path="$dir/herdr" + break + done + IFS="$save_ifs" + [ -n "$real_path" ] || return 1 + _REAL_HERDR_PATH="$real_path" + export _REAL_HERDR_PATH + printf '%s\n' "$real_path" +} + +# Preflight predicate: true iff a real herdr BINARY is installed (not the +# herdr() function, not the .mam/shim wrapper). Use this in every preflight +# instead of `command -v herdr` / `type -P herdr`. +has_real_herdr() { + _resolve_real_herdr_path >/dev/null 2>&1 +} +``` + +**설계 근거** + +| 결정 | 이유 | +|---|---| +| 🆕 **문자열 먼저, canonical 은 후보에만** | 정확성상 필요한 지점은 "받아들이려는 후보"뿐이다. PATH 전체를 canonical 화하는 것은 불필요하고 비싸다(§6-C 실측 **11.07 → 2.70 ms**, 서브셸 20회 → 1회). | +| 🆕 **`"/$dir/"` 정규화** | 선행 슬래시를 강제해 상대 항목 `.mam/shim` 까지 매칭. 절대경로는 무해한 `//` 접두부만 얻는다. 비용 0. | +| 🆕 **`_canonical_file` 을 직접 구현** | `readlink -f`/`realpath` 는 이 호스트와 최신 macOS 에는 있으나(실측 `/usr/bin/readlink`, `/bin/realpath`) 구형 macOS·최소 컨테이너에서는 보장되지 않는다. 프리플라이트가 선택적 도구에 의존해서는 안 된다. | +| **배열 미사용** | 실행 bash 가 3.2.57 이고 `set -u` 이므로 빈 배열 전개가 치명적이다(O-1 라운드 실측). `IFS` 순회 + `case` 만 사용. | +| **해석 깊이 40 제한** | 심링크 순환에서 무한 루프 방지. | +| **canonical 실패 시 리터럴 폴백** | 권한 없는 디렉터리 등에서 `cd -P` 가 실패해도 스캔이 중단되지 않도록. | + +**C-4 교차**: `_REAL_HERDR_PATH`, `_HERDR_SHIM_DIR_PATTERN`, `_HERDR_SKILLS_BIN_PATTERN` 3종이 미사용 → 사용 상태로 전환된다. C-4 목록에서 제외 필요(본 계획은 IMPROVEMENTS 를 수정하지 않음 — GM 재량). + +### F2 — `create_session.sh:84` + +```bash +# before +command -v herdr >/dev/null || type -P herdr >/dev/null || { echo "ERROR: herdr not installed" >&2; exit 1; } +# after +# B-3: `command -v herdr` matches lib.sh's herdr() function and `type -P herdr` +# matches the .mam/shim wrapper, so both pass with no herdr installed. +has_real_herdr || { echo "ERROR: herdr not installed" >&2; exit 1; } +``` +`lib.sh` 는 22행에서 이미 source 되므로 84행 시점에 정의되어 있다. + +### F3 — `multi-agent-mux-delegate-job:448` + +```bash +# before + if ! command -v herdr >/dev/null 2>&1; then +# after + # B-3: must probe for the real binary, not the herdr() function / shim wrapper. + if ! has_real_herdr; then +``` + +### F4 — 문서 2건 + +`create/SKILL.md:37`, `status/SKILL.md:31` 이 프리플라이트 예제로 `command -v herdr` 를 제시한다. **문서가 결함을 재생산하는 경로**이므로 함께 고친다(부록 B). + +### F5 — `tests/test_b3_herdr_preflight.py` (신규 **218줄**, Rev.1 163줄 → +55) + +`mock_herdr` 를 **의도적으로 쓰지 않고** `mam_sandbox` 만 사용하며, PATH 를 `BARE_PATH` 로 고정해 개발자 머신의 herdr 가 회귀를 가리지 못하게 한다. + +| ID | 검증 대상 | +|---|---| +| W-1 | 우회 2개 특성화 — 미래 리팩터가 one-liner 로 되돌리지 못하게 고정 | +| W-2 | 바이너리 없음 → FALSE | +| W-3 | 바이너리 있음 → TRUE, shim 이 아닌 실제 경로 반환 | +| W-4 | `my-shim` / `multi-agent-herdr-shim` 디렉터리는 불인정 (×2) | +| W-5 | `create_session.sh` rc≠0 **이고** 메시지가 herdr 게이트의 것 | +| W-6 | 실제 바이너리를 거부하지 않음 | +| W-7 | claude 만 있고 herdr 없음 → herdr 게이트에서 정지 | +| 🆕 **W-8** | **평범한 bin 디렉터리 안의 shim 심링크 → 거부** | +| 🆕 **W-9** | **실제 바이너리를 가리키는 심링크 → 수용** (전면 심링크 거부가 아님을 고정) | +| 🆕 **W-10** | **상대 PATH 항목 `.mam/shim` → 거부** | + +W-9 가 중요하다. W-8 만 있으면 "심링크는 전부 거부" 라는 잘못된 구현으로도 통과한다. W-9 가 그 오답을 배제한다. + +### F6 — `IMPROVEMENTS.md` + +게이트 통과 후 B-3 을 §5 완료로 이관, §2 헤더 `8건`→`7건`, §5 `4건`→`5건`, 총계 조정. B-3 본문에 §2-a(파일 수준 심링크)·§2-d(행 발생)를 반영 권고. + +--- + +## 4. 적용 순서 + +F1 → F2 → F3 → F5 → **red/green 측정** → F4 → **회귀 비교** → F6. + +--- + +## 5. 이의제기 심층 판정 + +### 5-A. 제시된 기제(디렉터리 심링크) — **기각** + +주장: `/var`↔`/private/var` 등 심링크 표기 차이로 `.mam/shim` 디렉터리 패턴이 우회된다. + +**반증**: §2-a 표 참조. 인용된 6개 심링크 형태가 **전부 SKIP** 된다. 구조적 이유가 있다 — 심링크 해석은 **접두부**를 바꾸고 패턴은 **접미부** `/.mam/shim/` 를 본다. 어떤 표기로 쓰든 그 디렉터리는 `.../.mam/shim` 으로 끝난다. **정규화가 판정을 바꿀 수 없다.** + +### 5-B. 제시된 처방(PATH 항목별 canonical 비교) — **기각** + +제안 코드는 실재하는 유일한 상대경로 구멍(`.mam/shim`)조차 **조건부로만** 닫는다. 실측: +``` +cwd == WORKSPACE_ROOT : canonical_dir=[/.mam/shim] → SKIP (구멍 닫힘) +cwd != WORKSPACE_ROOT : canonical_dir=[] → KEEP (구멍 열린 채) +``` +상대 `cd -P ".mam/shim"` 는 cwd 기준이므로 cwd 가 워크스페이스가 아니면 실패하고, 그 경우 비교가 성립하지 않아 통과한다. **cwd 가 `WORKSPACE_ROOT` 와 다른 것은 특수 상황이 아니라 일상적이다.** + +더불어 PATH 항목마다 서브셸을 띄운다(§6-C: 20항목 PATH 에서 **11.07 ms/call**, 후보 한정 방식의 **4.1배**). 성능은 부차적 근거이며 **기각의 주된 이유는 정확성**이다. + +### 5-C. 결론(심링크로 우회 가능) — **인용** + +**옳다.** 다만 기제가 디렉터리가 아니라 **파일**이다. 이의제기 문서의 마지막 권고에 *"실체 파일 경로/inode 를 배제"* 라는 표현이 스쳐 지나가는데, 제안 코드는 그것을 구현하지 않았다(디렉터리만 canonical 화). 실측으로 이 경로가 실재함을 확인하고(§2-a) Rev.2 에서 **후보 파일 canonical 화**로 닫았다. + +### 5-D. 자체 발견 — 상대 PATH 항목 + +Rev.1 검증 중 `.mam/shim`(선행 `/`·`./` 없음)이 패턴을 통과함을 발견했다. `_init_herdr_isolation` 은 절대경로를 넣으므로(`WORKSPACE_ROOT` 기본값이 `pwd` 출력) **이 경로로는 도달 불가**하며, 사람이나 외부 도구가 PATH 에 직접 넣어야 재현된다. 도달성은 낮지만 **비용 0의 한 토큰 수정**(`"$dir/"` → `"/$dir/"`)으로 닫히므로 닫았다. 도달성을 부풀리지 않고 기록한다. + +--- + +## 6. 검증 게이트 + +### G-A — 신규 테스트 red→green + +``` +HEAD (unpatched) : 2 failed, 5 passed in 0.37s [W-1..W-6] + + W-7 별도: FAILED (120s timeout, rc=-9) +Rev.1 : W-8 FAILED, W-10 FAILED, W-9 passed +Rev.2 (전량) : 11 passed in 0.66s +``` + +| 테스트 | HEAD | Rev.1 | Rev.2 | red 의 성격 | +|---|---|---|---|---| +| W-1 | PASS | PASS | PASS | 특성화 | +| W-2 | PASS† | PASS | PASS | 공허한 통과 | +| W-3 | FAIL | PASS | PASS | 오염된 red | +| W-4 | PASS† | PASS | PASS | 공허한 통과 | +| W-5 | **FAIL** | PASS | PASS | **진짜 red** | +| W-6 | PASS | PASS | PASS | 회귀 핀 | +| W-7 | **FAIL(hang)** | PASS | PASS | **진짜 red — 가장 강함** | +| 🆕 W-8 | — | **FAIL** | PASS | **진짜 red (Rev.1 대비)** | +| 🆕 W-9 | — | PASS | PASS | 오답 배제 핀 | +| 🆕 W-10 | — | **FAIL** | PASS | **진짜 red (Rev.1 대비)** | + +> **red 의 정직성 — 반드시 함께 읽을 것** +> **†W-2 / W-4** 는 HEAD 에서 `has_real_herdr` 가 **존재하지 않아** command-not-found → 비영 → FALSE 분기를 타서 **공허하게 통과**한다. red 가 아니라 회귀 핀이다. +> **W-3** 은 존재하지 않는 심볼을 호명해 실패하므로 **오염된 red**다. +> **W-5** 는 진짜 red 다. HEAD 에서 rc≠0 이지만 **herdr 게이트가 아니라 다음 claude 게이트에서** 죽는다. 단언을 `rc != 0` 하나로 뒀다면 **HEAD 에서 통과해 결함을 놓쳤을 것**이다. +> **W-7** 이 가장 강하다 — 새 심볼을 일절 호명하지 않고 관측 동작만 검사한다. HEAD 결과는 assertion failure 가 아니라 **120초 타임아웃 + SIGKILL** 이었다. +> **W-8 / W-10** 은 **Rev.1 대비 진짜 red** 다. 이 2건이 이번 이의제기의 실질 산출이다. +> → 누적 **진짜 red 4건**: W-5·W-7(HEAD 대비), W-8·W-10(Rev.1 대비). + +### G-B — 회귀 위험의 직접 측정 + +이 변경의 유일한 실질 회귀 위험은 **정당한 herdr 를 거부하는 것**이다. +``` +mock_herdr 픽스처와 동일 레이아웃(/bin/herdr) : ACCEPTED +실제 바이너리를 가리키는 심링크 : ACCEPTED (W-9) +bash 3.2 + set -euo pipefail 로 has_real_herdr : OK (rc=0) +``` + +### G-C — 🆕 비용 실측 (제안 방식 대비) + +20항목 PATH, herdr 는 마지막 디렉터리(양쪽 최악 조건): +``` + PATH 항목마다 canonical (agy 제안) : 11.07 ms/call 서브셸 20회 + 후보에만 canonical (Rev.2) : 2.70 ms/call 서브셸 1회 +``` +4.1배 차이. **다만 기각 사유의 주된 근거는 §5-B 의 정확성 문제이며 성능은 부차적이다.** + +### G-D — 회귀 비교 (동일 스위트, 두 클론) + +`create_session.sh` 를 구동하는 `tests/test_sanity.py` 를 patched / unpatched 동일 조건 실행: +``` +PATCHED : 2 failed in 194.52s HEAD : 2 failed in 194.71s + FAILED test_create_session_dry_run - assert '[dry-run] ... (양쪽 동일) + FAILED test_create_session_full - KeyError: 'isolation' (양쪽 동일) +``` +실패 집합·오류 메시지·소요 시간이 동일 → **선행 결함이며 본 변경이 유발한 회귀는 0건**이다. (`KeyError: 'isolation'` 은 C-3 영역으로 보이나 본 계획 범위 밖이며 수정하지 않는다.) + +> **전체 스위트(`pytest tests/`)는 실행하지 않았다.** 본 과제의 앞선 두 위임(`c3763464`, `a5639650`)이 정확히 이 시도에서 예산을 소진했다 — 호스트에서 실 오케스트레이션 루프가 동시 구동 중이라 pytest 가 반복적으로 SIGKILL(exit 137/144)됐다. 대신 **변경이 실제로 닿는 경로**를 두 클론에서 동일 비교하는 방식으로 판정했고, 이 비교는 절대 통과율과 무관하게 "내 변경이 유발했는가"에 결정적으로 답한다. Creator 는 한가한 시점에 `pytest tests/` 1회 완주를 권고한다. + +### G-E — 정적 검사 + +``` +bash -n lib.sh / create_session.sh / multi-agent-mux-delegate-job → syntax OK (3 files) +잔여 오지침(`command -v herdr` / `type -P herdr`) → 설명 주석 2건만 잔존 (지침 아님) +``` + +### 측정된 변경 규모 (Rev.2) + +``` + -2 +68 .agents/skills/lib.sh (Rev.1 +34 → +68) + -1 +3 .agents/skills/multi-agent-mux-create/scripts/create_session.sh + -1 +2 .agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job + -1 +4 .agents/skills/multi-agent-mux-create/SKILL.md + -1 +5 .agents/skills/multi-agent-mux-status/SKILL.md + new 218 tests/test_b3_herdr_preflight.py (Rev.1 163줄 → +55) +``` + +--- + +## 7. 리스크 + +| ID | 리스크 | 평가 / 완화 | +|---|---|---| +| **RK-A** | 정당한 herdr 설치를 거부 | G-B 로 직접 측정 — 픽스처 레이아웃·실 바이너리 심링크 모두 수용. W-3/W-6/W-9 가 핀. | +| **RK-B** | 정상 디렉터리명에 `-shim` 이 우연히 포함 | 해당 herdr 는 무시된다. 의도된 동작(shim 을 실제로 오인하는 편이 더 위험)이며 W-4 가 명시. `_HERDR_SHIM_DIR_PATTERN` 으로 조정 가능. | +| **RK-C** | bash 3.2 + `set -u` 이식성 | 배열 미사용 구현으로 회피. **G-B 에서 `/bin/bash` 3.2.57 + `set -euo pipefail` 직접 실행 확인.** | +| **RK-D** | PATH 빈 항목(`::`)이 `.` 로 해석 | `[ -n "$dir" ] \|\| continue` 로 방어. | +| **RK-E** 🆕 | `_canonical_file` 이 `cd -P` 실패 시 오작동 | 리터럴 경로로 폴백해 스캔을 계속한다. 최악의 경우 Rev.1 수준(문자열 패턴만)으로 **안전 퇴화**하며, 더 나빠지지 않는다. | +| **RK-F** 🆕 | 심링크 순환 | 해석 깊이 40 상한. 초과 시 마지막 경로로 판정 — 폴백과 동일하게 안전 퇴화. | +| **RK-G** 🆕 | 후보 canonical 화 비용 | 호출당 최대 1회 서브셸(G-C 실측 2.70 ms). 프리플라이트는 세션 생성/위임 시 1회만 호출되므로 무시 가능. | +| **RK-H** | C-4 목록이 낡음 | 심볼 3종이 사용 상태로 전환. C-4 에서 제외 필요 — GM 재량. | +| **RK-I** | 문서와 코드가 따로 감 | F2/F3(코드)와 F4(문서)를 한 커밋으로 묶을 것을 권고. 문서가 `command -v` 를 계속 가르치면 결함이 재도입된다. | +| **RK-J** | `test_sanity.py` 선행 실패 2건이 본 변경 탓으로 오인 | G-D 가 양쪽 클론 동일 실패를 명시. 리뷰 시 이 표를 근거로 제시할 것. | + +--- + +## 8. 역할 경계 + +본 문서는 **설계 산출물**이며 저장소 코드·문서는 **한 줄도 수정하지 않았다**. 모든 구현·측정은 세션 스크래치패드 내 `git archive HEAD` 클론 3벌(HEAD / Rev.1 / Rev.2)에서만 수행했고, 작업 트리는 `924e77e` 기준 clean 을 유지한다. + +`MULTI_AGENT_RULES.md` §1 에 따라 **구현은 Creator, 커밋은 GM 소관**이다. **차단 항목 없음.** + +--- + +## 부록 A — 신규 테스트 3종 전문 (W-8 / W-9 / W-10) + +Rev.1 의 W-1…W-7 은 그대로 유지된다. 아래는 Rev.2 추가분이다. + +```python +# W-8 — symlink bypass: a link in an ORDINARY bin dir pointing at the shim. +# No directory-name pattern can reject this; only the resolved target +# reveals it. +def test_b3_symlink_into_shim_is_rejected(mam_sandbox): + _bash(mam_sandbox, "true") # materialise the shim + shim = mam_sandbox / ".mam" / "shim" / "herdr" + assert shim.exists(), "shim wrapper was not created by _init_herdr_isolation" + + linkdir = mam_sandbox / "usrlocalbin" + linkdir.mkdir(parents=True, exist_ok=True) + (linkdir / "herdr").symlink_to(shim) + + res = _bash(mam_sandbox, + 'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi', + path=f"{linkdir}:{BARE_PATH}") + assert "RESULT:FALSE" in res.stdout, ( + "a symlink pointing into .mam/shim was accepted as a real herdr:\n" + f"{res.stdout}\n{res.stderr}") + + +# W-9 — ... but a symlink to a REAL binary must still be accepted, so the +# canonicalisation cannot be a blanket "reject all symlinks". +def test_b3_symlink_to_real_binary_is_accepted(mam_sandbox): + real = _make_herdr(mam_sandbox / "realbin") + linkdir = mam_sandbox / "linkbin" + linkdir.mkdir(parents=True, exist_ok=True) + (linkdir / "herdr").symlink_to(real) + + res = _bash(mam_sandbox, + 'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi', + path=f"{linkdir}:{BARE_PATH}") + assert "RESULT:TRUE" in res.stdout, \ + f"a symlink to a genuine herdr was rejected:\n{res.stdout}\n{res.stderr}" + + +# W-10 — a bare relative PATH entry '.mam/shim' (no leading '/' or './') must +# still be rejected. Requires normalising the dir with a leading slash. +def test_b3_bare_relative_shim_entry_is_rejected(mam_sandbox): + _bash(mam_sandbox, "true") # materialise the shim + res = _bash(mam_sandbox, + 'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi', + path=f".mam/shim:{BARE_PATH}") + assert "RESULT:FALSE" in res.stdout, ( + "a bare relative '.mam/shim' PATH entry was accepted:\n" + f"{res.stdout}\n{res.stderr}") +``` + +Rev.2 해석기에 대한 전 시나리오 실측: +``` + real binary : ACCEPT /realbin/herdr + symlink -> real : ACCEPT /goodlink/herdr + symlink -> shim : reject + 2-hop chain -> shim : reject + shim dir directly : reject + wrapper dir (my-shim): reject + nothing installed : reject +``` + +--- + +## 부록 B — 문서 수정 전문 (F4) + +**`create/SKILL.md`** +```bash +# 1) herdr available and isolated server status +# Use lib.sh's has_real_herdr, NOT `command -v herdr` / `type -P herdr`: once +# lib.sh is sourced the former matches its herdr() function and the latter +# matches the .mam/shim wrapper, so both pass on a host with no herdr (B-3). +has_real_herdr || { echo "ERROR: herdr not installed"; exit 1; } +``` + +**`status/SKILL.md`** +````markdown +```bash +has_real_herdr || { echo "ERROR: herdr not installed"; exit 1; } # lib.sh helper (B-3) +command -v python3 +test -f .mam/agent-sessions.yaml +``` + +> `command -v herdr` and `type -P herdr` both report success even when herdr is +> not installed — they match lib.sh's `herdr()` function and the `.mam/shim` +> wrapper respectively. Always use `has_real_herdr`. +```` + +--- + +[AGREEMENT: REACHED] diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-e73917f8.md b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-e73917f8.md new file mode 100644 index 0000000..33fdee3 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-e73917f8.md @@ -0,0 +1,71 @@ +# Cross-Code Review — Job e73917f8 (B-3: `command -v herdr` preflight bypass) + +- **Job**: e73917f8 +- **Reviewer**: cline +- **Target**: Resolve B-3 — `command -v herdr` falsely matches the `herdr()` bash function in `lib.sh` (and `type -P herdr` matches the `.mam/shim` wrapper), so pre-flight passes on a host with no real `herdr` binary. +- **Date**: 2026-08-06 + +## 1. Scope & Diff Summary + +6 files modified (+68 / −14) plus 1 new test suite (untracked): + +| File | Change | +|---|---| +| `.agents/skills/lib.sh` | +44: new `_canonical_file()`, `_is_shim_path()`, rewritten `_resolve_real_herdr_path()` (PATH scan skipping shim dirs + canonicalised symlink target), new `has_real_herdr()`. Both `_HERDR_SHIM_DIR_PATTERN` and `_HERDR_SKILLS_BIN_PATTERN` defined before use. | +| `.agents/skills/multi-agent-mux-create/scripts/create_session.sh` | preflight `command -v herdr \|\| type -P herdr` → `has_real_herdr` | +| `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job` | preflight `command -v herdr` → `has_real_herdr` (in `run_agent()`) | +| `.agents/skills/multi-agent-mux-create/SKILL.md` | doc preflight → `has_real_herdr` + explanatory comment | +| `.agents/skills/multi-agent-mux-status/SKILL.md` | doc preflight → `has_real_herdr` + blockquote warning | +| `IMPROVEMENTS.md` | B-3 moved §2(open, 8→7) → §5(completed, 5→6); counts/roadmap updated | +| `tests/test_b3_herdr_preflight.py` | new, 196 lines, 10 functions / 11 cases (W-1…W-10, W-4 parametrised ×2) | + +## 2. Verification Performed + +| Check | Method | Result | +|---|---|---| +| Bash syntax | `bash -n` on lib.sh, create_session.sh, delegate-job | **PASS** (all 3) | +| B-3 unit tests | `pytest tests/test_b3_herdr_preflight.py -v` | **11/11 PASS** (0.67s) | +| Regression | `pytest tests/test_workspace_scope.py tests/test_b1_tier3_identity.py -q` | **10/10 PASS** | +| Lint (shellcheck) | `command -v shellcheck` | **Not installed** in env — pre-existing limitation; `bash -n` substitutes. Not introduced by B-3. | +| Completeness | `grep -rn 'command -v herdr\|type -P herdr' .agents/skills --include='*.sh'` | Only an **explanatory comment** (create_session.sh:84) remains; **no executable preflight left**. All 4 preflight sites migrated. | +| Source-order | grep `lib.sh`/`has_real_herdr` in each script | lib.sh sourced **before** `has_real_herdr` at both call sites (create_session.sh:22→86; delegate-job:46→449). create_session.sh additionally proven e2e by W-5/W-6/W-7. | + +## 3. Logic Audit + +### 3.1 The two bypasses (confirmed by W-1) +- `command -v herdr` → matches `herdr()` shell **function** (lib.sh:540 → `mam_herdr`). +- `type -P herdr` → matches `$WORKSPACE_ROOT/.mam/shim/herdr`, because `_init_herdr_isolation` runs at **source time** (lib.sh:1784) and prepends the shim dir to PATH. +Both pass on a herdr-less host → preflight was void. W-1 documents this so a future refactor cannot silently regress. + +### 3.2 New resolver (`_resolve_real_herdr_path` + `_is_shim_path` + `_canonical_file`) +- **Critical check PASSED**: `_HERDR_SHIM_DIR_PATTERN` (line 48, `/multi-agent-herdr-shim/`) and `_HERDR_SKILLS_BIN_PATTERN` (line 49, `/.agents/skills/.bin`) are defined **before** `_is_shim_path` references them. An unset var would have made the glob `**` match every path (false-reject all). No such bug. +- `_is_shim_path` matches `"/$1/"` (leading+trailing slash normalisation) against: `*/.mam/shim/*`, `*-shim/*`, `*/multi-agent-herdr-shim/*`, `*/.agents/skills/.bin/*`. Bare relative entry `.mam/shim` → `/.mam/shim/` → caught (W-10). +- **Double shim check**: dir-level (line 81) **and** canonical-file-level (line 85). A symlink in a normal bin dir pointing into `.mam/shim` is resolved by `_canonical_file` (readlink loop ≤40 hops + `cd -P`/`pwd -P`) and rejected on the resolved target (W-8). A symlink to a **real** binary is accepted (W-9) — canonicalisation is not a blanket symlink rejection. +- IFS save/restore around `for dir in $PATH`; restored after the loop **even on `break`**. Empty PATH elements skipped (safe — never picks cwd). `_canonical_file` failure falls back to the literal path. Sound. + +### 3.3 Operability — no `herdr()` regression +- `herdr()` (lib.sh:540) → `mam_herdr()` (lib.sh:531) → executes `$WORKSPACE_ROOT/.mam/shim/herdr` directly. It does **not** use lib.sh's `_REAL_HERDR_PATH`. +- The shim wrapper generated by `_init_herdr_isolation` (lib.sh:112-180) carries its **own** embedded `_resolve_real_herdr()` (lib.sh:118-132) and a local `REAL_HERDR` — fully self-contained. +- Old code set `_REAL_HERDR_PATH="herdr"` unconditionally at definition; new code sets it only inside `_resolve_real_herdr_path` when a real binary is found. Since **no consumer** (`herdr()`/shim wrapper) depends on `_REAL_HERDR_PATH` being pre-set, removing the unconditional default is **safe**. Confirmed by regression suite (10/10) and the e2e W-5/W-6/W-7 tests. + +### 3.4 IMPROVEMENTS.md arithmetic +- Open: **15** = 2 (arch) + 7 (edge, was 8, −B-3) + 2 (orch) + 4 (legacy). ✓ +- Completed: **6** = A-1, A-5, B-1, **B-3**, C-1, O-1. ✓ +## 4. Findings + +### R-1 (Minor, Non-blocking) — Broad `-shim/*` pattern is a conservative false-positive +`_is_shim_path`'s `*-shim/*` glob will reject a **legitimate** herdr installed in any directory whose canonical path contains the substring `-shim/` (e.g. `/opt/foo-shim/bin/herdr`). This fails **closed** (preflight says "not installed" when it is) — the safe direction — and is explicitly tested behaviour (W-4 with `my-shim`/`multi-agent-herdr-shim`). Acceptable for a preflight gate; a user hitting this can place herdr in a differently-named dir or override `_HERDR_SHIM_DIR_PATTERN`. No action required; recorded for awareness. + +### R-2 (Minor, Non-blocking, pre-existing / out-of-scope) — Shim wrapper's own resolver is less strict +The shim wrapper's embedded `_resolve_real_herdr()` (lib.sh:118-132) uses a simpler dir-name-substring check (no canonical-file symlink resolution) than the new lib.sh `has_real_herdr`. Thus a symlink-in-normal-dir pointing into `.mam/shim` is rejected by the **preflight** but would, if preflight were bypassed, still be selectable by the shim wrapper's resolver (potential recursion). This is a **pre-existing** characteristic of the shim wrapper, **not touched** by the B-3 diff, and the new preflight gate now catches it first. Not a B-3 defect; noted as a future consistency follow-up. + +### R-3 (Info) — shellcheck gate not runnable here +shellcheck is not installed in this environment, so the IMPROVEMENTS §7-D shellcheck gate could not be re-run for the changed files. Pre-existing environment limitation, unchanged by B-3. `bash -n` syntax validation PASS on all three scripts. + +## 5. Conclusion + +B-3 is **fully and correctly resolved**. The root cause (two independent bypasses — `herdr()` function match + `.mam/shim` wrapper match) is correctly diagnosed and fixed with a real-binary PATH scan that skips shim/wrapper dirs at both the directory-name and canonicalised-symlink-target levels. All 4 preflight sites are migrated; no executable `command -v herdr`/`type -P herdr` preflight remains. The fix cannot regress the `herdr()`→shim-wrapper runtime path (independent of `_REAL_HERDR_PATH`). 11/11 new tests + 10/10 regression tests PASS. IMPROVEMENTS.md bookkeeping is arithmetically consistent. Findings R-1/R-2/R-3 are all non-blocking (R-1/R-2 are conservative-direction or out-of-scope; R-3 is an env limitation). + +No design-level rework is warranted — this is a clean, well-tested bug fix. + +[VERDICT: PASS] \ No newline at end of file diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index c54102f..acb8878 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -50,9 +50,51 @@ _HERDR_SKILLS_BIN_PATTERN="${_HERDR_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}" HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" +_canonical_file() { + local p="$1" t d b i=0 + while [ -L "$p" ] && [ "$i" -lt 40 ]; do + t="$(readlink "$p" 2>/dev/null)" || break + case "$t" in + /*) p="$t" ;; + *) p="$(dirname "$p")/$t" ;; + esac + i=$((i + 1)) + done + d="$(cd -P "$(dirname "$p")" 2>/dev/null && pwd -P)" || return 1 + b="$(basename "$p")" + printf '%s/%s\n' "$d" "$b" +} + +_is_shim_path() { + case "/$1/" in + *"/.mam/shim/"*|*-shim/*|*"$_HERDR_SHIM_DIR_PATTERN"*|*"$_HERDR_SKILLS_BIN_PATTERN"/*) + return 0 ;; + esac + return 1 +} + _resolve_real_herdr_path() { - _REAL_HERDR_PATH="herdr" + local dir cand save_ifs="$IFS" real_path="" + IFS=: + for dir in $PATH; do + [ -n "$dir" ] || continue + _is_shim_path "$dir" && continue + [ -x "$dir/herdr" ] || continue + cand="$(_canonical_file "$dir/herdr" 2>/dev/null)" || cand="$dir/herdr" + [ -n "$cand" ] || cand="$dir/herdr" + _is_shim_path "$cand" && continue + real_path="$dir/herdr" + break + done + IFS="$save_ifs" + [ -n "$real_path" ] || return 1 + _REAL_HERDR_PATH="$real_path" export _REAL_HERDR_PATH + printf '%s\n' "$real_path" +} + +has_real_herdr() { + _resolve_real_herdr_path >/dev/null 2>&1 } _init_herdr_isolation() { diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 14d7344..862b318 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -34,7 +34,10 @@ Before doing anything, verify the environment: ```bash # 1) herdr available and isolated server status -command -v herdr || { echo "ERROR: herdr not installed"; exit 1; } +# Use lib.sh's has_real_herdr, NOT `command -v herdr` / `type -P herdr`: once +# lib.sh is sourced the former matches its herdr() function and the latter +# matches the .mam/shim wrapper, so both pass on a host with no herdr (B-3). +has_real_herdr || { echo "ERROR: herdr not installed"; exit 1; } echo "Herdr session name: ${HERDR_SESSION_NAME:-default}" # 2) claude / agy available diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index e0b4d86..bb05a95 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -81,7 +81,9 @@ fi [ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; usage; exit 2; } [ -n "$ROLE" ] || { echo "ERROR: --role required" >&2; usage; exit 2; } [ -d "$WORKSPACE" ] || { echo "ERROR: workspace $WORKSPACE not a directory" >&2; exit 1; } -command -v herdr >/dev/null || type -P herdr >/dev/null || { echo "ERROR: herdr not installed" >&2; exit 1; } +# B-3: `command -v herdr` matches lib.sh's herdr() function and `type -P herdr` +# matches the .mam/shim wrapper, so both pass with no herdr installed. +has_real_herdr || { echo "ERROR: herdr not installed" >&2; exit 1; } command -v "$AGENT" >/dev/null || { echo "ERROR: $AGENT CLI not in PATH" >&2; exit 1; } # Auth Check (OAuth check for agy, loggedIn check for claude, status for hermes) diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index b4307ea..3581f04 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -445,7 +445,8 @@ run_agent() { return fi - if ! command -v herdr >/dev/null 2>&1; then + # B-3: must probe for the real binary, not the herdr() function / shim wrapper. + if ! has_real_herdr; then echo "ERROR: this skill requires herdr (interactive agent sessions)." >&2 echo " Ensure herdr is installed and executable." >&2 return 1 diff --git a/.agents/skills/multi-agent-mux-status/SKILL.md b/.agents/skills/multi-agent-mux-status/SKILL.md index 40d0561..c7a46a5 100644 --- a/.agents/skills/multi-agent-mux-status/SKILL.md +++ b/.agents/skills/multi-agent-mux-status/SKILL.md @@ -28,11 +28,15 @@ This is the "what's running right now?" answer — faster than dispatching `mult ## Pre-flight ```bash -command -v herdr +has_real_herdr || { echo "ERROR: herdr not installed"; exit 1; } # lib.sh helper (B-3) command -v python3 test -f .mam/agent-sessions.yaml ``` +> `command -v herdr` and `type -P herdr` both report success even when herdr is +> not installed — they match lib.sh's `herdr()` function and the `.mam/shim` +> wrapper respectively. Always use `has_real_herdr`. + If `agent-sessions.yaml` doesn't exist or is malformed → print clear error, exit 1. **Do not create it.** (Use `multi-agent-mux-create` first.) ## Workflow diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 16a0898..bf0ff05 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -1,9 +1,9 @@ # 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`) -- **최종 갱신일**: 2026-08-06 (O-1 Rebuttal & Re-adjudication Protocol 완료 반영) +- **최종 갱신일**: 2026-08-06 (B-3 herdr real binary preflight verification 완료 반영) - **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md` -- **총 추적 미해결 과제**: **16건** (아키텍처 2건, 엣지케이스 8건, 오케스트레이션 2건, 레거시 잔재 4건) -- **완료된 과제**: **5건** (A-1, A-5, B-1, C-1, O-1) +- **총 추적 미해결 과제**: **15건** (아키텍처 2건, 엣지케이스 7건, 오케스트레이션 2건, 레거시 잔재 4건) +- **완료된 과제**: **6건** (A-1, A-5, B-1, B-3, C-1, O-1) --- @@ -25,10 +25,7 @@ --- -## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 8건) - -### **B-3: `command -v herdr` 프리플라이트 무력화** -- `create_session.sh`의 프리플라이트 검사 시 `command -v herdr`가 `lib.sh`에 정의된 bash 함수(`herdr()`)를 호명하여 실제 시스템 `herdr` 바이너리가 없어도 프리플라이트를 무조건 통과해버립니다. +## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 7건) ### **B-4: 시프트 `ls`의 `created=0` 하드코딩으로 재개 가드 무력화** - `herdr ls` 서브커맨드 래퍼가 세션 생성시각을 상수 `0`으로 리턴하여 `reconcile.sh` drift-B 등록 시 epoch 0이 되어 오래된 대화 jsonl 배제 가드가 붕괴됩니다. @@ -87,7 +84,7 @@ --- -## 5. 🎉 완료된 과제 (Completed Tasks — 5건) +## 5. 🎉 완료된 과제 (Completed Tasks — 6건) ### **A-1: 워크스페이스 세션 격리 & drift-B 오등록 방지** — ✅ 완료 - `derive_workspace_slug` 헬퍼 함수를 추가하여 워크스페이스 경로 기반 단일 소켓 슬러그(`mam--`) 도출 체계를 구축했습니다. @@ -100,6 +97,11 @@ - `lib.sh` tier-3 신원 캐시 조회 시 미정의 변수(`db_path`, `yaml_path`) 및 `yaml` import 누락으로 무조건 `NameError` 예외가 발생하던 결함을 해결했습니다. - DB를 1차 권위 경로로, `$YAML_PATH`를 폴백으로 정제하고 전용 회귀 테스트 `tests/test_b1_tier3_identity.py` (8/8 PASS)를 작성하여 입증했습니다. +### **B-3: `command -v herdr` 프리플라이트 무력화** — ✅ 완료 +- `lib.sh`에 `_canonical_file()`, `_is_shim_path()`, `_resolve_real_herdr_path()`, `has_real_herdr()` 헬퍼를 작성하여 `herdr()` bash 함수 오판과 `.mam/shim/herdr` 래퍼 매칭(파일 수준 심링크 포함)을 완전 차단했습니다. +- `create_session.sh`, `multi-agent-mux-delegate-job`, `create/SKILL.md`, `status/SKILL.md` 프리플라이트를 `has_real_herdr`로 전수 교체했습니다. +- 회귀 테스트 `tests/test_b3_herdr_preflight.py` (11/11 PASS)를 작성하여 입증했습니다. + ### **C-1: Kanban 문서 29회 언급 vs 실제 구현 0건** — ✅ 완료 - SKILL.md 3종(monitor 22 / status 5 / create 2)과 README 2종의 Kanban 서술을 전면 제거했습니다. - `multi-agent-mux-monitor` 의 실행 메커니즘 서술을 실제 구현인 `reconcile.sh --subscribe` (MQTT push + 브로커 다운 시 폴링 폴백) 기준으로 재작성했습니다. @@ -117,4 +119,4 @@ ## 6. 결론 및 향후 보완 로드맵 -`IMPROVEMENTS.md` 문서에 따라 향후 코드베이스 개편 시 남은 백로그 항목(아키텍처 2건, 엣지케이스 8건, 오케스트레이션 2건, 레거시 잔재 4건)을 일원화된 보완 로드맵으로 관리합니다. +`IMPROVEMENTS.md` 문서에 따라 향후 코드베이스 개편 시 남은 백로그 항목(아키텍처 2건, 엣지케이스 7건, 오케스트레이션 2건, 레거시 잔재 4건)을 일원화된 보완 로드맵으로 관리합니다. diff --git a/tests/test_b3_herdr_preflight.py b/tests/test_b3_herdr_preflight.py new file mode 100644 index 0000000..87d795f --- /dev/null +++ b/tests/test_b3_herdr_preflight.py @@ -0,0 +1,196 @@ +"""B-3 — the herdr pre-flight must probe for the real BINARY. + +Two independent bypasses make the obvious one-liners useless once lib.sh is +sourced: + + * ``command -v herdr`` matches the ``herdr()`` shell FUNCTION (lib.sh:498). + * ``type -P herdr`` matches ``$WORKSPACE_ROOT/.mam/shim/herdr``, because + ``_init_herdr_isolation`` runs at source time + (lib.sh bottom) and prepends that dir to PATH. + +So a host with no herdr installed at all passes pre-flight, and the failure is +deferred to a confusing runtime error from the shim. +""" +import os +import stat +import subprocess + +import pytest + +# A PATH with no herdr anywhere. Kept deliberately minimal so a herdr that +# happens to be installed on the developer's box cannot mask a regression. +BARE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin" + + +def _bash(sandbox, snippet, path=BARE_PATH, extra_env=None): + env = dict(os.environ) + env["PATH"] = path + env["WORKSPACE_ROOT"] = str(sandbox) + if extra_env: + env.update(extra_env) + script = f'source "{sandbox}/.agents/skills/lib.sh" >/dev/null 2>&1\n{snippet}' + return subprocess.run(["bash", "-c", script], capture_output=True, + text=True, cwd=str(sandbox), env=env) + + +def _make_herdr(directory): + directory.mkdir(parents=True, exist_ok=True) + exe = directory / "herdr" + exe.write_text("#!/bin/sh\necho fake herdr\n") + exe.chmod(exe.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return exe + + +# W-1 — document the two bypasses so a future refactor cannot silently +# "simplify" has_real_herdr back into one of them. +def test_b3_command_v_and_type_p_both_falsely_report_herdr(mam_sandbox): + res = _bash(mam_sandbox, """ +command -v herdr >/dev/null && echo "command-v:MATCH" || echo "command-v:miss" +echo "type-t:$(type -t herdr)" +type -P herdr >/dev/null 2>&1 && echo "type-P:MATCH($(type -P herdr))" || echo "type-P:miss" +""") + assert "command-v:MATCH" in res.stdout, res.stdout + assert "type-t:function" in res.stdout, res.stdout + assert "type-P:MATCH" in res.stdout, res.stdout + assert "/.mam/shim/herdr" in res.stdout, \ + f"type -P was expected to resolve the shim wrapper:\n{res.stdout}" + + +# W-2 — has_real_herdr must be FALSE when no binary is installed +def test_b3_has_real_herdr_false_without_binary(mam_sandbox): + res = _bash(mam_sandbox, + 'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi') + assert "RESULT:FALSE" in res.stdout, \ + f"has_real_herdr passed with no herdr binary:\n{res.stdout}\n{res.stderr}" + + +# W-3 — ... and TRUE when one is, resolving past the shim +def test_b3_has_real_herdr_true_with_binary(mam_sandbox): + real = _make_herdr(mam_sandbox / "realbin") + res = _bash(mam_sandbox, + 'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi\n' + 'echo "RESOLVED:$(_resolve_real_herdr_path)"', + path=f"{mam_sandbox / 'realbin'}:{BARE_PATH}") + assert "RESULT:TRUE" in res.stdout, f"{res.stdout}\n{res.stderr}" + assert f"RESOLVED:{real}" in res.stdout, \ + f"resolved the shim instead of the real binary:\n{res.stdout}" + + +# W-4 — a herdr that only exists inside a wrapper/shim dir does not count +@pytest.mark.parametrize("dirname", ["my-shim", "multi-agent-herdr-shim"]) +def test_b3_shim_directories_do_not_satisfy_preflight(mam_sandbox, dirname): + _make_herdr(mam_sandbox / dirname) + res = _bash(mam_sandbox, + 'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi', + path=f"{mam_sandbox / dirname}:{BARE_PATH}") + assert "RESULT:FALSE" in res.stdout, \ + f"a herdr inside wrapper dir '{dirname}' was accepted:\n{res.stdout}" + + +# W-5 — create_session.sh pre-flight must actually FAIL without a binary. +# This is the defect B-3 names. +def test_b3_create_session_preflight_fails_without_herdr(mam_sandbox): + script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + env = dict(os.environ) + env["PATH"] = BARE_PATH + env["WORKSPACE_ROOT"] = str(mam_sandbox) + res = subprocess.run( + ["bash", str(script), "--workspace", str(mam_sandbox), + "--agent", "claude", "--role", "worker"], + capture_output=True, text=True, cwd=str(mam_sandbox), env=env) + + assert res.returncode != 0, \ + f"pre-flight passed with no herdr installed:\n{res.stdout}\n{res.stderr}" + assert "herdr not installed" in (res.stdout + res.stderr), \ + f"failed, but not on the herdr pre-flight:\n{res.stdout}\n{res.stderr}" + + +# W-6 — and must still pass the herdr gate when a binary IS present. +# (It may fail later on the agent-CLI gate; that is a different check.) +def test_b3_create_session_preflight_passes_herdr_gate_with_binary(mam_sandbox): + _make_herdr(mam_sandbox / "realbin") + script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + env = dict(os.environ) + env["PATH"] = f"{mam_sandbox / 'realbin'}:{BARE_PATH}" + env["WORKSPACE_ROOT"] = str(mam_sandbox) + res = subprocess.run( + ["bash", str(script), "--workspace", str(mam_sandbox), + "--agent", "claude", "--role", "worker"], + capture_output=True, text=True, cwd=str(mam_sandbox), env=env) + + assert "herdr not installed" not in (res.stdout + res.stderr), \ + f"herdr gate rejected a real binary:\n{res.stdout}\n{res.stderr}" + + +# W-7 — the sharpest statement of B-3: with the agent CLI present and ONLY +# herdr missing, pre-flight must stop on the herdr gate. This is the +# test that is genuinely red before the fix without naming any new +# helper, so it measures the defect rather than the implementation. +def test_b3_preflight_stops_on_herdr_gate_when_only_herdr_is_missing(mam_sandbox): + agentbin = mam_sandbox / "agentbin" + agentbin.mkdir(parents=True, exist_ok=True) + for name in ("claude",): + exe = agentbin / name + exe.write_text('#!/bin/sh\necho \'{"loggedIn": true}\'\n') + exe.chmod(exe.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + env = dict(os.environ) + env["PATH"] = f"{agentbin}:{BARE_PATH}" # claude yes, herdr no + env["WORKSPACE_ROOT"] = str(mam_sandbox) + res = subprocess.run( + ["bash", str(script), "--workspace", str(mam_sandbox), + "--agent", "claude", "--role", "worker"], + capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=120) + + combined = res.stdout + res.stderr + assert "herdr not installed" in combined, ( + "pre-flight did not stop on the herdr gate even though herdr is absent.\n" + f"rc={res.returncode}\n--- stdout ---\n{res.stdout}\n--- stderr ---\n{res.stderr}") + + +# W-8 — symlink bypass: a link in an ORDINARY bin dir pointing at the shim. +# No directory-name pattern can reject this; only the resolved target +# reveals it. +def test_b3_symlink_into_shim_is_rejected(mam_sandbox): + _bash(mam_sandbox, "true") # materialise the shim + shim = mam_sandbox / ".mam" / "shim" / "herdr" + assert shim.exists(), "shim wrapper was not created by _init_herdr_isolation" + + linkdir = mam_sandbox / "usrlocalbin" + linkdir.mkdir(parents=True, exist_ok=True) + (linkdir / "herdr").symlink_to(shim) + + res = _bash(mam_sandbox, + 'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi', + path=f"{linkdir}:{BARE_PATH}") + assert "RESULT:FALSE" in res.stdout, ( + "a symlink pointing into .mam/shim was accepted as a real herdr:\n" + f"{res.stdout}\n{res.stderr}") + + +# W-9 — ... but a symlink to a REAL binary must still be accepted, so the +# canonicalisation cannot be a blanket "reject all symlinks". +def test_b3_symlink_to_real_binary_is_accepted(mam_sandbox): + real = _make_herdr(mam_sandbox / "realbin") + linkdir = mam_sandbox / "linkbin" + linkdir.mkdir(parents=True, exist_ok=True) + (linkdir / "herdr").symlink_to(real) + + res = _bash(mam_sandbox, + 'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi', + path=f"{linkdir}:{BARE_PATH}") + assert "RESULT:TRUE" in res.stdout, \ + f"a symlink to a genuine herdr was rejected:\n{res.stdout}\n{res.stderr}" + + +# W-10 — a bare relative PATH entry '.mam/shim' (no leading '/' or './') must +# still be rejected. Requires normalising the dir with a leading slash. +def test_b3_bare_relative_shim_entry_is_rejected(mam_sandbox): + _bash(mam_sandbox, "true") # materialise the shim + res = _bash(mam_sandbox, + 'if has_real_herdr; then echo RESULT:TRUE; else echo RESULT:FALSE; fi', + path=f".mam/shim:{BARE_PATH}") + assert "RESULT:FALSE" in res.stdout, ( + "a bare relative '.mam/shim' PATH entry was accepted:\n" + f"{res.stdout}\n{res.stderr}")