# 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]