Files

484 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 → <ws>/.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 <ws>/.mam/shim/herdr <ws>/symbin/herdr
$ PATH=<ws>/symbin:... ; has_real_herdr
Rev.1 -> ACCEPTED <-- shim 래퍼를 진짜 herdr 로 오판
resolved: <ws>/symbin/herdr
(readlink -> <ws>/.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 -> [<WS>/.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
<Popen: returncode: -9 ...> ← 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 -> <ws>/.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=[<ws>/.mam/shim] → SKIP (구멍 닫힘)
cwd != WORKSPACE_ROOT : canonical_dir=[<empty>] → 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 픽스처와 동일 레이아웃(<tmp>/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 <ws>/realbin/herdr
symlink -> real : ACCEPT <ws>/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]