From b490713471a20e0d7e49e31902cffa9b3b4d520d Mon Sep 17 00:00:00 2001 From: Godopu Date: Sat, 15 Aug 2026 10:46:39 +0900 Subject: [PATCH] fix(loop): eliminate tmp script copy and trap leak in delegate_job_safe (P2-1/B-6) --- .../multi-agent-mux-loop/scripts/run_loop.sh | 26 ++- IMPROVEMENTS.md | 24 ++- LOG.md | 12 +- tests/test_o3_scoped_guard.py | 167 +++++++++++++++++- 4 files changed, 211 insertions(+), 18 deletions(-) diff --git a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh index 1abf2c4..d640532 100644 --- a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh @@ -87,16 +87,27 @@ fi MAM_LOOP_MARKER="${MAM_LOOP_MARKER:-$REPO_ROOT/.mam/loop-guard-active}" _mam_release_guard() { mam_release_loop_lock "$MAM_LOOP_MARKER" || true; } +# Runs the delegate-job wrapper in place. Deliberately creates no copy and +# installs no trap: +# * a copy inside .agents/skills/ pollutes the source tree and leaks on +# SIGKILL (B-6). It never protected across turns anyway — the copy is made +# per call, so a wrapper broken in turn N is copied broken in turn N+1; +# * every call site is a command substitution, so a trap set here fires when +# that subshell ends. `$$` is still the parent's pid there, so +# _mam_release_guard passed its ownership check and dropped the loop lock +# after the first delegated job (D1). +# The callers' own "Failed to register ..." branches are unreachable when the +# wrapper exits non-zero (set -e aborts the assignment first), so the diagnosis +# has to be emitted here. delegate_job_safe() { local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job" - local tmp_script - tmp_script="${orig_script}.${RANDOM}_$$.tmp" - cp "$orig_script" "$tmp_script" - trap 'rm -f "$tmp_script"' EXIT INT TERM HUP local rc=0 - bash "$tmp_script" "$@" || rc=$? - rm -f "$tmp_script" - trap _mam_release_guard EXIT INT TERM HUP + bash "$orig_script" "$@" || rc=$? + if [ "$rc" -ne 0 ]; then + log_error "delegate_job_safe failed (exit $rc): $orig_script" + log_error " if this loop edits framework skills in place, check that file's syntax:" + log_error " bash -n \"$orig_script\"" + fi return $rc } @@ -134,6 +145,7 @@ case "$_mam_acquire_rc" in ;; esac trap _mam_release_guard EXIT INT TERM HUP +rm -f "$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job".*.tmp 2>/dev/null || true # --all-reviewer silently takes precedence over an explicit --reviewer list; # warn so the discarded list isn't mistaken for having been honored (P2-1). diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 6b241b3..6f6af57 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -74,8 +74,20 @@ - **실측(ecef05a3)**: `df --output=target` 은 여전히 `rc=64` 로 실패하나 `ea36e81` 에서 추가된 `df -P` 폴백이 정상 동작합니다(macOS 실측: `/System/Volumes/Data`). **결론("감지 실패")은 더 이상 참이 아니므로 종결을 권고합니다.** - **잔여분 → B-11 로 분리 권고**: `mount | grep -E "$mountpoint.*(nfs|cifs|smb|sshfs)"` 가 마운트포인트를 이스케이프 없이 ERE 에 보간하여 경로의 `.` 이 임의 문자로 해석됩니다(이론상 오탐). -### **B-6: 스킬 트리에 임시 파일 복사 및 유출** -- `run_loop.sh::delegate_job_safe`가 래퍼 스크립트를 `.agents/skills/...` 트리 내부에 `.tmp`로 복사하여 버전 관리 트리를 오염시키고 rsync 배포 시 외부로 유출됩니다. +### **B-6: 스킬 트리에 임시 파일 복사 및 유출** — ✅ **완료 (Stage 1)** +- `run_loop.sh::delegate_job_safe`가 래퍼 스크립트를 `.agents/skills/...` 트리 내부에 `.tmp`로 복사하여 버전 관리 트리를 오염시키고 rsync 배포 시 외부로 유출되던 문제를, 임시 사본 생성 없이 원본 스크립트를 직접 인플레이스 실행(`bash "$orig_script" "$@"`)하도록 개선하여 해결했습니다. +- 기존에 이미 존재하던 완화 조치(`.gitignore:18`의 `*.tmp`, `deploy/install_mam.sh:126`의 `--exclude='*.tmp'`, `deploy/install.sh:190`의 `*.tmp` skip)에 더해, 소스 트리 내 누출 경로 자체를 소멸시켰습니다. +- `run_loop.sh` 기동 시 잔여 `.tmp` 스윕 구문(`rm -f .../multi-agent-mux-delegate-job.*.tmp`) 및 실패 시 진단 로깅을 추가했습니다. + +### **B-12: 명령 치환 서브셸의 EXIT 트랩으로 인한 루프 락 조기 해제 (D1)** — ✅ **완료 (P0)** +- `run_loop.sh` 내 `PLAN_JOB_OUTPUT=$(delegate_job_safe submit ...)` 등 11개 호출부가 명령 치환(`$(...)`) 서브셸로 실행될 때, `delegate_job_safe` 내부의 `trap _mam_release_guard EXIT INT TERM HUP` 이 서브셸 종료 시 발화하는 결함입니다. +- bash 서브셸의 `$$` 가 부모 PID를 유지하므로 `mam_release_loop_lock` 의 소유권 검사를 통과하여 첫 번째 위임 잡 종료 시점에 `.mam/loop-guard-active` 마커가 삭제되었습니다. +- 이로 인해 O-3 Invocation-Aware Scoped Guard의 파일 수정 차단 및 O-2 단일 루프 락이 첫 번째 잡 이후 무력화되는 P0 결함을 `delegate_job_safe` 내 로컬 트랩을 전면 제거함으로써 근본 해결했습니다. +- `tests/test_o3_scoped_guard.py` 내 Z-9 테스트를 3종 행위 기반 테스트(`test_z9_loop_lock_survives_delegation`, `test_z9_probe_detects_the_defect`, `test_z9_no_tmp_copy_left_in_skill_tree`, `test_z9_exit_code_and_diagnostics_propagation`)로 교체하여 재발을 방지했습니다. + +### **B-13: 셀프 호스팅 멀티에이전트 루프 중 턴 간 스킬 오염 (In-Flight Tooling Mutation)** — 🟡 **Stage 2 분리 과제** +- `/multi-agent-mux-loop` 가 `multi-agent-mux` 자체를 수정/리팩터링할 때, Turn 1에서 Worker가 `.agents/skills/...` 를 수정(문법 오류나 미완성 코드 포함)하면 후속 Turn 2의 Reviewer 잡 제출 시 래퍼가 비정상 종료되어 자가 치유(Corrective/Rebuttal) 단계로 진입하지 못하고 루프가 중단될 수 있는 위험입니다. +- Stage 1에서는 실패 시 명확한 구문 진단 로그(`log_error "delegate_job_safe failed (exit $rc)..."`)를 제공하도록 보강하였으며, 근본 해결을 위해 루프 기동 시 1회 임시 디렉터리에 런타임 스냅샷을 동결하는 Stage 2 구현(래퍼 지시문 경로 오버라이드 포함)이 제안되었습니다. ### **B-8: `send_keys_safe` agy 경로 검증 이탈** - agy 세션 주입 시 주입 실패 여부를 검증하지 않고 무조건 `return 0`을 남겨 실패 시에도 성공으로 보고됩니다. @@ -108,7 +120,13 @@ --- -## 5. 🎉 완료된 과제 (Completed Tasks — 12건) +## 5. 🎉 완료된 과제 (Completed Tasks — 13건) + +### **P2-1 (B-6 / B-12): `delegate_job_safe` 임시 사본 제거 및 서브셸 루프 락 조기 해제 차단 조치** — ✅ 완료 +- `.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh` 내 `delegate_job_safe` 가 `.agents/skills/...` 경로 내에 `.tmp` 사본을 생성하던 방식을 제거하고 원본 래퍼 스크립트를 인플레이스로 직접 실행(`bash "$orig_script" "$@"`)하도록 개선하여 버전 관리 트리 오염 및 rsync 배포 유출(B-6)을 완전히 해소했습니다. +- 명령 치환(`$(delegate_job_safe ...)`) 서브셸 내에 설치되던 `trap _mam_release_guard EXIT` 로 인해 첫 번째 위임 잡 종료 시점에 `.mam/loop-guard-active` 마커가 삭제되어 O-3 Scoped Guard 및 O-2 락이 무력화되던 인접 P0 결함(**B-12 / D1**)을 로컬 트랩 제거를 통해 근본 해결했습니다. +- 래퍼 스크립트 실행 실패 시 도달 불가능하던 에러 진단을 `delegate_job_safe` 내부에서 직접 표준오류로 출력(`log_error "delegate_job_safe failed (exit $rc)..."`)하도록 진단 로깅을 보강했습니다. +- `tests/test_o3_scoped_guard.py` 내 Z-9 테스트를 4개 세부 행위 기반 테스트(`test_z9_loop_lock_survives_delegation`, `test_z9_probe_detects_the_defect`, `test_z9_no_tmp_copy_left_in_skill_tree`, `test_z9_exit_code_and_diagnostics_propagation`)로 교체 검증했습니다. ### **multi-agent-mux-orc-onboard: 오케스트레이터 온보딩 스킬 및 `orchestrator_uuids` 배제 게이트 조치** — ✅ 완료 - `.agents/skills/multi-agent-mux-orc-onboard/` 스킬 및 `.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh` 헬퍼 모듈을 생성하여, 메인 오케스트레이터가 프로젝트 맥락 파악 시 자신의 대화 UUID를 포착해 `.mam/agent-sessions.yaml` 및 SQLite DB 내 `orchestrator_uuids` 리스트로 원자적 기록하는 구조를 구축했습니다. diff --git a/LOG.md b/LOG.md index 7222ec9..fab1ae7 100644 --- a/LOG.md +++ b/LOG.md @@ -1,6 +1,6 @@ # 📝 Multi-Agent Mux 작업 세션 기록 (`LOG.md`) -- **최종 기록일시**: 2026-08-08 00:23 (KST) +- **최종 기록일시**: 2026-08-15 10:45 (KST) - **작업 저장소**: `tmpl/multi-agent-mux` (Branch: `main`) - **작업 상태**: 모든 작업 완료, 세션 안전 종료(stopped), 저장소 상태 Clean! @@ -8,7 +8,15 @@ ## 📌 1. 금일 작업 내용 요약 -### 1) **multi-agent-mux-orc-onboard: 오케스트레이터 온보딩 스킬 및 `orchestrator_uuids` 배제 게이트 구축** — **완료** +### 1) **P2-1 (B-6 / B-12): `delegate_job_safe` 임시 사본 제거 및 서브셸 루프 락 조기 해제 차단 조치** — **완료** +- **배경**: `run_loop.sh::delegate_job_safe` 가 `.agents/skills/...` 내부에 `.tmp` 사본을 생성하여 트리 오염 및 배포 시 유출(B-6)되던 문제와, 명령 치환 서브셸 내의 `trap _mam_release_guard EXIT` 로 인해 첫 번째 잡 위임 시 루프 락 마커(`.mam/loop-guard-active`)가 조기 삭제되어 O-3 가드레일이 무력화되던 결함(**B-12 / D1**) 조치. +- **주요 구현**: + - [`.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh): `delegate_job_safe` 를 임시 사본 및 서브셸 트랩 없이 인플레이스로 직접 실행(`bash "$orig_script" "$@"`)하도록 개선하고 실패 시 진단 로깅 추가. 루프 기동 시 기존 잔여 `.tmp` 스윕 구문 추가. + - [`IMPROVEMENTS.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/IMPROVEMENTS.md): B-6 완료 상태 갱신, B-12 (D1) 결함 명세 및 B-13 (턴 간 스킬 오염) Stage 2 과제 등록. + - [`tests/test_o3_scoped_guard.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/tests/test_o3_scoped_guard.py): 취약한 문자열 검사 Z-9를 4개 행위 기반 테스트(`test_z9_loop_lock_survives_delegation`, `test_z9_probe_detects_the_defect`, `test_z9_no_tmp_copy_left_in_skill_tree`, `test_z9_exit_code_and_diagnostics_propagation`)로 교체. +- **검증**: `pytest tests/ -q` 실행 결과 **259 passed (100%)** 달성. + +### 2) **multi-agent-mux-orc-onboard: 오케스트레이터 온보딩 스킬 및 `orchestrator_uuids` 배제 게이트 구축** — **완료** - **배경**: 오케스트레이터(`agy`)가 서브 에이전트 생성/정지/복원 시 자기 대화 UUID가 `agent-sessions.yaml` 서브 세션으로 오염 캡처되어 SQLite DB 락(`database is locked`) 및 대화 충돌이 발생하던 결함 조치. - **주요 구현**: - [`.agents/skills/multi-agent-mux-orc-onboard/SKILL.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md) 및 [`.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh): 오케스트레이터의 신원 UUID를 포착하여 `.mam/agent-sessions.yaml` 및 SQLite DB 내 `orchestrator_uuids` 리스트로 원자적 등록하는 스킬 구축. diff --git a/tests/test_o3_scoped_guard.py b/tests/test_o3_scoped_guard.py index fab3242..c089458 100644 --- a/tests/test_o3_scoped_guard.py +++ b/tests/test_o3_scoped_guard.py @@ -147,16 +147,171 @@ def test_z8_dead_pid_marker(mam_sandbox): assert res["decision"] == "allow" -# Z-9: delegate_job_safe restores _mam_release_guard trap -def test_z9_delegate_job_safe_restores_trap(): +def _extract_delegate_job_safe(): run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh" content = run_loop.read_text() - # Check inside delegate_job_safe definition that trap - is replaced with trap _mam_release_guard func_start = content.find("delegate_job_safe() {") assert func_start != -1 - func_body = content[func_start:func_start+400] - assert "trap _mam_release_guard EXIT INT TERM HUP" in func_body - assert "trap - EXIT INT TERM HUP" not in func_body + func_end = content.find("\n}\n", func_start) + assert func_end != -1 + return content[func_start:func_end + 3] + + +# Z-9: delegate_job_safe preserves loop lock across delegations without tmp copies/traps (B-6, D1) +def test_z9_loop_lock_survives_delegation(tmp_path): + loop_lock_sh = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh" + skill_dir = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" + skill_dir.mkdir(parents=True) + stub_wrapper = skill_dir / "multi-agent-mux-delegate-job" + stub_wrapper.write_text("#!/usr/bin/env bash\necho 'JOB_ID: 12345678'\n") + stub_wrapper.chmod(0o755) + + extracted_func = _extract_delegate_job_safe() + script = f"""#!/usr/bin/env bash +set -euo pipefail +REPO_ROOT="{tmp_path}" +MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active" +source "{loop_lock_sh}" + +log_error() {{ echo "ERROR: $@" >&2; }} +log_info() {{ echo "INFO: $@" >&2; }} + +_mam_release_guard() {{ mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }} + +mam_acquire_loop_lock "$MAM_LOOP_MARKER" +trap _mam_release_guard EXIT INT TERM HUP + +{extracted_func} + +PLAN_JOB_OUTPUT=$(delegate_job_safe submit --task test) + +if [ -f "$MAM_LOOP_MARKER" ]; then + echo "MARKER: HELD" +else + echo "MARKER: RELEASED" +fi +""" + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + assert res.returncode == 0 + assert "MARKER: HELD" in res.stdout + + +def test_z9_probe_detects_the_defect(tmp_path): + loop_lock_sh = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh" + skill_dir = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" + skill_dir.mkdir(parents=True) + stub_wrapper = skill_dir / "multi-agent-mux-delegate-job" + stub_wrapper.write_text("#!/usr/bin/env bash\necho 'JOB_ID: 12345678'\n") + stub_wrapper.chmod(0o755) + + defective_func = """ +delegate_job_safe() { + local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job" + local tmp_script + tmp_script="${orig_script}.${RANDOM}_$$.tmp" + cp "$orig_script" "$tmp_script" + trap 'rm -f "$tmp_script"' EXIT INT TERM HUP + local rc=0 + bash "$tmp_script" "$@" || rc=$? + rm -f "$tmp_script" + trap _mam_release_guard EXIT INT TERM HUP + return $rc +} +""" + script = f"""#!/usr/bin/env bash +set -euo pipefail +REPO_ROOT="{tmp_path}" +MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active" +source "{loop_lock_sh}" + +log_error() {{ echo "ERROR: $@" >&2; }} +log_info() {{ echo "INFO: $@" >&2; }} + +_mam_release_guard() {{ mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }} + +mam_acquire_loop_lock "$MAM_LOOP_MARKER" +trap _mam_release_guard EXIT INT TERM HUP + +{defective_func} + +PLAN_JOB_OUTPUT=$(delegate_job_safe submit --task test) + +if [ -f "$MAM_LOOP_MARKER" ]; then + echo "MARKER: HELD" +else + echo "MARKER: RELEASED" +fi +""" + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + assert res.returncode == 0 + assert "MARKER: RELEASED" in res.stdout + + +def test_z9_no_tmp_copy_left_in_skill_tree(tmp_path): + loop_lock_sh = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh" + skill_dir = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" + skill_dir.mkdir(parents=True) + stub_wrapper = skill_dir / "multi-agent-mux-delegate-job" + stub_wrapper.write_text("#!/usr/bin/env bash\necho 'JOB_ID: 12345678'\n") + stub_wrapper.chmod(0o755) + + extracted_func = _extract_delegate_job_safe() + script = f"""#!/usr/bin/env bash +set -euo pipefail +REPO_ROOT="{tmp_path}" +MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active" +source "{loop_lock_sh}" + +log_error() {{ echo "ERROR: $@" >&2; }} +log_info() {{ echo "INFO: $@" >&2; }} + +_mam_release_guard() {{ mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }} + +mam_acquire_loop_lock "$MAM_LOOP_MARKER" +trap _mam_release_guard EXIT INT TERM HUP + +{extracted_func} + +PLAN_JOB_OUTPUT=$(delegate_job_safe submit --task test) +""" + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + assert res.returncode == 0 + tmp_files = list(skill_dir.glob("*.tmp")) + assert tmp_files == [] + + +def test_z9_exit_code_and_diagnostics_propagation(tmp_path): + loop_lock_sh = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh" + skill_dir = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" + skill_dir.mkdir(parents=True) + stub_wrapper = skill_dir / "multi-agent-mux-delegate-job" + stub_wrapper.write_text("#!/usr/bin/env bash\necho 'syntax error' >&2\nexit 7\n") + stub_wrapper.chmod(0o755) + + extracted_func = _extract_delegate_job_safe() + script = f"""#!/usr/bin/env bash +REPO_ROOT="{tmp_path}" +MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active" +source "{loop_lock_sh}" + +log_error() {{ echo "ERROR: $@" >&2; }} +log_info() {{ echo "INFO: $@" >&2; }} + +_mam_release_guard() {{ mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }} + +mam_acquire_loop_lock "$MAM_LOOP_MARKER" +trap _mam_release_guard EXIT INT TERM HUP + +{extracted_func} + +rc=0 +delegate_job_safe submit --task test || rc=$? +echo "DELEGATE_RC: $rc" +""" + res = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + assert "DELEGATE_RC: 7" in res.stdout + assert "delegate_job_safe failed (exit 7):" in res.stderr + assert "bash -n" in res.stderr # Z-10: Reused PID with stale lstart is NOT blocked