fix(b3): eliminate command -v herdr bash function false positive in preflight checks (100% PASS)
This commit is contained in:
@@ -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 → <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]
|
||||
@@ -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]
|
||||
+43
-1
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-9
@@ -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-<parent>-<work>`) 도출 체계를 구축했습니다.
|
||||
@@ -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건)을 일원화된 보완 로드맵으로 관리합니다.
|
||||
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user