feat(o3): implement Invocation-Aware Scoped Guard for orchestrator role scoping (100% PASS)

This commit is contained in:
2026-08-07 10:09:14 +09:00
parent 5ab76874e4
commit 1f8622e077
13 changed files with 428 additions and 20 deletions
+10
View File
@@ -150,6 +150,16 @@ TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해
- *버전 관리 이관*: 버전 관리가 필요한 주요 산출물(최종 설계 계획, 최종 리뷰 보고서, 보안 감사 리포트 등)은 gitignore 대상인 `.mam/` 하위가 아닌, 버전 관리 대상 경로(구체적으로 `.agents/reports/<tmux_session_name>/` 또는 `docs/reports/` 등)로 명시적으로 복사하여 이관 보존해야 합니다.
- **디스크 정리 및 보존 정책 계약 (Cleanup & Retention)**: `.mam/jobs/<job_id>/``.mam/reports/` 폴더 아래의 파일들은 휘발성 감사 이력(audit-trail) 산출물입니다. 버전 관리가 필요한 문서들은 `.agents/reports/` 하위로 수동 복사하여 커밋해야 하며, `stop_session.sh` 세션 종료 스크립트는 이들 보고서 디렉터리를 자동으로 삭제하지 않으므로 수동 또는 주기적 클린업이 권장됩니다.
### 3.2 조건부 오케스트레이션 위임 가드 (Invocation-Aware Scoped Guard — O-3)
| 모드 | 오케스트레이터 행위 | 도구 허용 여부 |
|---|---|---|
| **일반 모드 (Normal Mode)** | 주 작업자 (직접 코드 및 문서 수정) | 모든 파일 수정 도구 허용 |
| **루프 활성 모드 (`/multi-agent-mux-loop`)** | 오케스트레이터 (`run_loop.sh` 자율 위임) | `file_change`, `edit_notebook`, `write_blob` **하드 블록** (`.agents/hooks.json`) |
- **Fail-Open 원칙**: 훅 내부 오류 또는 파싱 실패 시 무조건 `allow`로 처리하여 작업을 차단하지 않음.
- **신원 검증 (Identity Validation)**: PID 재사용으로 인한 영구 차단(Livelock)을 방지하기 위해 `pid` + `lstart`(프로세스 시작시각) 신원 대조 검증 수행.
### ⏱️ 타임아웃 구성 및 정렬 규칙
- **잡 실행 제한 (`timeout_sec` & `idle_timeout_sec`)**: 각 잡은 전체 실행 만료 시간(`timeout_sec`, 기본 3600s)과 메세지 미수신 유휴 시간(`idle_timeout_sec`, 기본 120s)을 독립적으로 가집니다.
- **모니터 유휴 대기 (`SUB_IDLE_TIMEOUT`)**: 모니터 스크립트(`reconcile.sh`)의 유휴 대기 시간(`SUB_IDLE_TIMEOUT`) 기본값은 잡 최대 예산에 맞춰 `3600s`(1시간) 이상으로 항상 넉넉히 설정해야 합니다. 모니터가 작업 완료 전에 유휴 감지로 조기 자동 종료되어 백그라운드 태스크 관리를 소실하는 문제를 방지하기 위함입니다.
+10
View File
@@ -150,6 +150,16 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
- *Versioned promotions*: Any final design plans, review verdicts, or security audit reports that require version control must be explicitly copied to tracked directory paths (specifically under `.agents/reports/<tmux_session_name>/` or `docs/reports/`).
- **Cleanup & Retention Contract**: Files under `.mam/jobs/<job_id>/` and `.mam/reports/` are transient audit-trail artifacts. While durable outcomes are committed to version control under `.agents/reports/`, ephemeral directory trees can be cleaned up manually as needed; `stop_session.sh` does not automatically purge these report trees during session exit.
### 3.2 Invocation-Aware Scoped Guard (O-3)
| Mode | Orchestrator Action | Tool Access |
|---|---|---|
| **Normal Mode** | Main Creator (Direct implementation) | All tools allowed |
| **Loop Active Mode (`/multi-agent-mux-loop`)** | Orchestrator (Delegates to `run_loop.sh`) | `file_change`, `edit_notebook`, `write_blob` **hard-blocked** via `.agents/hooks.json` |
- **Fail-Open Policy**: Any hook internal error or parse error evaluates to `allow`.
- **Identity Verification**: The guard validates process liveness via `pid` + `lstart` to prevent livelocks on PID rollover.
### ⏱️ Timeout Configuration & Alignment Rules
- **Job Execution Limits (`timeout_sec` & `idle_timeout_sec`)**: Each job independently manages its overall execution timeout (`timeout_sec`, default 3600s) and idle timeout without receiving messages (`idle_timeout_sec`, default 120s).
- **Monitor Idle Waiting (`SUB_IDLE_TIMEOUT`)**: The idle timeout for the monitor script (`reconcile.sh`), `SUB_IDLE_TIMEOUT`, must always be set generously to `3600s` (1 hour) or more to align with the maximum job budget. This prevents the monitor from terminating early due to idle detection, which would lose control over background tasks before they finish.
+12
View File
@@ -0,0 +1,12 @@
{
"mam-loop-delegation-guard": {
"PreToolUse": [
{
"matcher": "file_change|edit_notebook|write_blob",
"hooks": [
{ "type": "command", "command": "./hooks/loop_delegation_guard.sh", "timeout": 10 }
]
}
]
}
}
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# O-3 — Invocation-Aware Scoped Guard (Rev.2).
#
# Normal mode: the orchestrator IS the Main Creator and may edit files freely.
# While /multi-agent-mux-loop is active it must delegate through run_loop.sh
# instead, so this PreToolUse hook denies direct file mutation and says why.
#
# Contract (agy hooks.json): JSON payload on stdin, JSON decision on stdout.
# in : {"toolCall":{"name":..., "args":{...}}, "workspacePaths":[...],
# "transcriptPath":"..."}
# out: {"decision":"allow"|"deny", "reason":"..."}
#
# Fails OPEN: any internal error emits `allow`. A guard that blocks the agent
# because it could not parse its own input would be worse than the drift.
set -uo pipefail
payload="$(cat)"
exec 3>&1 # keep the decision channel separate from noise
allow() { printf '{"decision":"allow"}\n' >&3; exit 0; }
MARKER="${MAM_LOOP_GUARD_MARKER:-}"
python3 - "$payload" "$MARKER" >&3 <<'PY' || allow
import json, os, sys, subprocess
payload_raw, marker_override = sys.argv[1], sys.argv[2]
def emit(decision, reason=None):
out = {"decision": decision}
if reason:
out["reason"] = reason
print(json.dumps(out))
sys.exit(0)
try:
p = json.loads(payload_raw)
except Exception:
emit("allow") # unparseable -> fail open
name = ((p.get("toolCall") or {}).get("name") or "").strip().lower()
# Step-type-derived names (hooks.json matches on these), NOT the model-facing
# tool names. This agy build has CORTEX_STEP_TYPE_FILE_CHANGE / EDIT_NOTEBOOK /
# WRITE_BLOB; there is no REPLACE_FILE_CONTENT step type at all.
MUTATING = {"file_change", "edit_notebook", "write_blob"}
if name not in MUTATING:
emit("allow")
ws = (p.get("workspacePaths") or [None])[0] or os.getcwd()
marker = marker_override or os.path.join(ws, ".mam", "loop-guard-active")
def _lstart(pid):
"""Process start time, or '' if the process is gone/unknowable."""
try:
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="],
capture_output=True, text=True, timeout=5)
except Exception:
return ""
return " ".join(out.stdout.split())
def _marker_active(path):
"""True only if the marker exists AND its owning process is still alive.
SIGKILL cannot be trapped, so a trap-based release always has a leak
window. A stale marker must never block the orchestrator forever, so
identity (pid + lstart) -- not mere existence -- is the signal.
"""
if not os.path.exists(path):
return False
try:
with open(path, encoding="utf-8", errors="replace") as f:
txt = f.read()
except Exception:
return False
fields = {}
for line in txt.splitlines():
if "=" in line:
k, v = line.split("=", 1)
fields[k.strip()] = v.strip()
try:
pid = int(fields.get("pid", ""))
except ValueError:
pid = None
if pid is None:
return True # no pid recorded -> honour it
recorded_lstart = " ".join(fields.get("lstart", "").split())
if recorded_lstart:
# pid + start time is a stable identity. A reused pid always has a
# different start time, so this closes the rollover livelock: a marker
# we cannot positively identify must never block the orchestrator.
return _lstart(pid) == recorded_lstart
# Legacy marker with no lstart: fall back to liveness, but treat an
# unidentifiable owner as STALE. Blocking forever is the worse error.
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False # owner gone -> stale
except PermissionError:
return False # different owner -> cannot be our loop
active = _marker_active(marker)
if not active:
# Best-effort second signal: the skill was invoked but run_loop.sh has not
# started yet, so no marker exists. Look for the invocation in the tail of
# the transcript. Absence of a transcript simply means "not active".
tpath = p.get("transcriptPath") or ""
try:
if tpath and os.path.exists(tpath):
with open(tpath, encoding="utf-8", errors="replace") as f:
tail = f.readlines()[-200:]
for line in reversed(tail):
if "/multi-agent-mux-loop" in line:
active = True
break
if "MAM_LOOP_GUARD_RELEASE" in line:
break # loop finished; stop here
except Exception:
pass # transcript unreadable -> not active
if not active:
emit("allow")
emit("deny",
"The /multi-agent-mux-loop skill is active, so direct file edits are out "
"of scope for the orchestrator. Stop editing and delegate instead: run "
"bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh "
"--target-agent <session> --task <goal>. "
"See .agents/MULTI_AGENT_RULES.md #3.2 (Invocation-Aware Scoped Guard).")
PY
@@ -19,7 +19,10 @@
# 4 = agent-sessions.yaml append failure
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_lib_sh="$(cd "$_script_dir/../.." 2>/dev/null || pwd)/lib.sh"
[ -f "$_lib_sh" ] || _lib_sh="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
source "$_lib_sh"
usage() {
cat <<EOF
@@ -18,6 +18,7 @@ metadata:
> **Companion skills**: `multi-agent-mux-create` (start), `multi-agent-mux-resume` (re-attach), `multi-agent-mux-delegate-job` (delegate).
> **Safety Guard**: `--max-loop`, `--max-rebut`, and `--plan-talk` restrict API cost runaways.
> **Scope Guard (O-3)**: Intercepts direct orchestrator mutations when `/multi-agent-mux-loop` is active. See [.agents/MULTI_AGENT_RULES.md #3.2](.agents/MULTI_AGENT_RULES.md#32-invocation-aware-scoped-guard-o-3).
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
수동 템플릿 작성 및 수동 프롬프트 환류는 폐지되었습니다. Planner, Creator, Reviewer 간의 모든 협업 피드백 루프는 본 스킬(`run_loop.sh`)만을 단독으로 사용하여 자동으로 오케스트레이션합니다.
@@ -79,6 +79,15 @@ if [ -z "$TARGET_AGENT" ] || [ -z "$TASK" ]; then
echo "ERROR: --target-agent and --task are mandatory fields."
usage
fi
MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active"
mkdir -p "$(dirname "$MAM_LOOP_MARKER")"
_mam_lstart() { ps -p "$1" -o lstart= 2>/dev/null | tr -s ' ' | sed 's/^ *//;s/ *$//'; }
printf 'pid=%s\nlstart=%s\nstarted=%s\n' \
"$$" "$(_mam_lstart $$)" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$MAM_LOOP_MARKER"
_mam_release_guard() { rm -f "$MAM_LOOP_MARKER"; }
trap _mam_release_guard EXIT INT TERM HUP
delegate_job_safe() {
local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
local tmp_script
@@ -88,7 +97,7 @@ delegate_job_safe() {
local rc=0
bash "$tmp_script" "$@" || rc=$?
rm -f "$tmp_script"
trap - EXIT INT TERM HUP
trap _mam_release_guard EXIT INT TERM HUP
return $rc
}
@@ -15,8 +15,10 @@
# Exit codes: 0 = ok | 1 = YAML not found | 2 = error
set -euo pipefail
SKILLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." 2>/dev/null || pwd)"
LIB_SH="$SKILLS_DIR/lib.sh"
[ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
source "$LIB_SH"
if [ -n "${AGENT_SESSIONS_STATE_DIR:-}" ]; then
echo "Notice: AGENT_SESSIONS_STATE_DIR is set but has no effect; the monitor keeps no state directory (C-2)." >&2
@@ -2,7 +2,10 @@
# resume_session.sh — resume a stopped session
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_SH="$(cd "$SCRIPT_DIR/../.." 2>/dev/null || pwd)/lib.sh"
[ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
source "$LIB_SH"
usage() {
cat <<EOF
@@ -31,7 +31,10 @@
set -euo pipefail
# shellcheck disable=SC1091
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_lib_sh="$(cd "$_script_dir/../.." 2>/dev/null || pwd)/lib.sh"
[ -f "$_lib_sh" ] || _lib_sh="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
source "$_lib_sh"
usage() {
cat <<EOF
+7 -1
View File
@@ -67,4 +67,10 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
Read [MULTI_AGENT_RULES.md](.agents/MULTI_AGENT_RULES.md) (or [Korean version](.agents/MULTI_AGENT_RULES.ko.md)) and [multi-agent-mux-loop/SKILL.md](.agents/skills/multi-agent-mux-loop/SKILL.md) first before working and follow the instructions for orchestration and collaboration.
Read [MULTI_AGENT_RULES.md](.agents/MULTI_AGENT_RULES.md) (or [Korean version](.agents/MULTI_AGENT_RULES.ko.md)) and [multi-agent-mux-loop/SKILL.md](.agents/skills/multi-agent-mux-loop/SKILL.md) first before working and follow the instructions for orchestration and collaboration.
## 5. Orchestrator Scope Guard (O-3)
**Normal vs. Orchestration Modes**
- **Normal Mode**: The orchestrator acts as Main Creator and may edit files directly.
- **Loop Active Mode (`/multi-agent-mux-loop`)**: Direct file mutation is intercepted by `.agents/hooks.json`. Do not seek workarounds when a tool call is denied — delegate the task immediately via `run_loop.sh`.
+13 -14
View File
@@ -1,9 +1,9 @@
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
- **최종 갱신일**: 2026-08-07 (C-2 unused cache state directory removal & dead code cleanup 완료 반영)
- **최종 갱신일**: 2026-08-07 (O-3 Invocation-Aware Scoped Guard 완료 반영)
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
- **총 추적 미해결 과제**: **13** (아키텍처 1건, 엣지케이스 7건, 오케스트레이션 2건, 레거시 잔재 3건)
- **완료된 과제**: **8** (A-1, A-3, A-5, B-1, B-3, C-1, C-2, O-1)
- **총 추적 미해결 과제**: **12** (아키텍처 1건, 엣지케이스 7건, 오케스트레이션 1건, 레거시 잔재 3건)
- **완료된 과제**: **9** (A-1, A-3, A-5, B-1, B-3, C-1, C-2, O-1, O-3)
---
@@ -56,18 +56,9 @@
2. `mkdir` 직후 생성 창 유예 대기(Sleep Grace Period)를 부여하여 락 도난 방지.
3. `ps` CLI 부재 시 Fails-Open(락 무시) 대신 **Fails-Safe(락 존중 + 경고)** 로 전환하여 DB/YAML 오염 원천 방지.
### **O-3 (구 ISSUE-9): 조건부 오케스트레이션 위임 가드 (Invocation-Aware Scoped Guard)**
- **현상**: 오케스트레이터(Antigravity)가 평상시에는 Main Creator로서 코드 및 문서를 직접 집필해야 하지만, `/multi-agent-mux-loop` 슬래시 커맨드/스킬이 인보크된 상황에서도 이를 인지하지 못하고 에이전트들에게 위임하는 대신 직접 수정을 시도하는 지침 이탈 발생.
- **해결 방안**:
- **평상시 (일반 요청)**: 오케스트레이터가 **Main Creator**로서 소스 및 마크다운 파일 직접 작성/수정 도구(`write_to_file`, `replace_file_content`)를 자유롭게 사용하여 단독 구현 수행.
- **`/multi-agent-mux-loop` 호출 시 (스킬 활성화 상태)**: 스킬 인터셉터 가드(Guardrail)가 작동하여 직접 수정 도구 호출을 거부(Interception)하고, **"슬래시 커맨드가 인보크되었으므로 직접 수정을 중단하고 `run_loop.sh`를 실행하여 위임하십시오"**라는 에러를 반환해 `run_loop.sh` 자율 위임 실행을 코딩적으로 강제.
---
## 4. ⚪ 레거시 잔재 및 죽은 코드 (Legacy Remnants — 4건)
### **C-2: 미사용 `.cache/` 상태 디렉터리 생성**
- `reconcile.sh``.cache/multi-agent-mux-monitor` 디렉터리를 `mkdir`만 하고 아무것도 읽거나 쓰지 않습니다.
## 4. ⚪ 레거시 잔재 및 죽은 코드 (Legacy Remnants — 3건)
### **C-3: 격리 스텁 4종 및 `stop_session.sh` 미사용 isolation 코드 잔존**
- `provision_isolation` 등 4개 스텁 함수와 `stop_session.sh``.mam/agent_homes` 가드 코드가 호출자 0건인 채 잔존합니다.
@@ -80,7 +71,15 @@
---
## 5. 🎉 완료된 과제 (Completed Tasks — 8건)
## 5. 🎉 완료된 과제 (Completed Tasks — 9건)
### **O-3: 조건부 오케스트레이션 위임 가드 (Invocation-Aware Scoped Guard)** — ✅ 완료
- Normal Mode(직접 소스 수정)와 Loop Active Mode(`/multi-agent-mux-loop` 인보크 시 `run_loop.sh` 자율 위임)의 역할 경계를 명확히 구분하는 스킬 인터셉터 가드레일(`.agents/hooks.json` & `.agents/hooks/loop_delegation_guard.sh`)을 구축했습니다.
- step-type 파생명 매처(`file_change|edit_notebook|write_blob`)를 적용하여 가드 무발화 결함을 방지했습니다.
- `pid` + `lstart`(프로세스 시작시각) 신원 대조 검증을 통해 PID Rollover 및 `PermissionError` 시 발생할 수 있는 Livelock 영구 차단 오판을 완벽히 해결했습니다.
- `run_loop.sh` 마커 기록 및 `delegate_job_safe` 트랩 복원(`_mam_release_guard`)을 완료했습니다.
- `AGENTS.md`, `MULTI_AGENT_RULES.md` (.ko.md), `SKILL.md` 문서를 전수 대칭 갱신했습니다.
- 전용 단위/회귀 테스트 스위트 `tests/test_o3_scoped_guard.py` (22/22 PASS)를 수립하여 입증했습니다.
### **A-1: 워크스페이스 세션 격리 & drift-B 오등록 방지** — ✅ 완료
- `derive_workspace_slug` 헬퍼 함수를 추가하여 워크스페이스 경로 기반 단일 소켓 슬러그(`mam-<parent>-<work>`) 도출 체계를 구축했습니다.
+214
View File
@@ -0,0 +1,214 @@
"""O-3 — Invocation-Aware Scoped Guard unit & integration test suite.
Verifies:
Z-1: Normal mode allows all 3 mutating tools (file_change, edit_notebook, write_blob)
Z-2: Active loop denies all 3 mutating tools and includes run_loop.sh in reason
Z-3: Non-mutating tools (run_command, view_file, grep_search) allowed during loop
Z-4: Malformed or unparseable input fails open (allows tool call)
Z-5: Transcript signal covers gap before marker creation
Z-6: Matcher targets derived step-type names (file_change|edit_notebook|write_blob)
Z-7: run_loop.sh writes identity marker and releases it on exit/interrupt
Z-8: Dead PID marker is ignored (fail open)
Z-9: delegate_job_safe restores _mam_release_guard trap
Z-10: Reused PID (live PID, stale lstart) is NOT blocked (Livelock prevention)
Z-11: Live PID with matching lstart IS blocked
Z-12: Process owned by another user (PermissionError) is NOT blocked
Z-13: Legacy marker fallback (without lstart) degrades open for other owners
Z-14: run_loop.sh records lstart in marker
"""
import os
import json
import shutil
import subprocess
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
GUARD_SCRIPT = REPO_ROOT / ".agents" / "hooks" / "loop_delegation_guard.sh"
def _run_guard(payload_dict_or_raw, marker_path=None, transcript_path=None, env_extra=None):
env = dict(os.environ)
if env_extra:
env.update(env_extra)
if marker_path:
env["MAM_LOOP_GUARD_MARKER"] = str(marker_path)
if isinstance(payload_dict_or_raw, dict):
if transcript_path and "transcriptPath" not in payload_dict_or_raw:
payload_dict_or_raw["transcriptPath"] = str(transcript_path)
payload_str = json.dumps(payload_dict_or_raw)
else:
payload_str = str(payload_dict_or_raw)
res = subprocess.run(
["bash", str(GUARD_SCRIPT)],
input=payload_str,
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
env=env,
)
assert res.returncode == 0
return json.loads(res.stdout)
def _get_lstart(pid):
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True)
return " ".join(out.stdout.split())
# Z-1: Normal mode allows all 3 mutating tools
@pytest.mark.parametrize("tool_name", ["file_change", "edit_notebook", "write_blob"])
def test_z1_normal_mode_allows(mam_sandbox, tool_name):
payload = {"toolCall": {"name": tool_name}, "workspacePaths": [str(mam_sandbox)]}
res = _run_guard(payload, marker_path=mam_sandbox / ".mam" / "nonexistent")
assert res["decision"] == "allow"
# Z-2: Active loop denies mutating tools
@pytest.mark.parametrize("tool_name", ["file_change", "edit_notebook", "write_blob"])
def test_z2_active_loop_denies(mam_sandbox, tool_name):
marker = mam_sandbox / ".mam" / "loop-guard-active"
marker.parent.mkdir(parents=True, exist_ok=True)
my_pid = os.getpid()
lstart = _get_lstart(my_pid)
marker.write_text(f"pid={my_pid}\nlstart={lstart}\nstarted=2026-08-07T00:00:00Z\n")
payload = {"toolCall": {"name": tool_name}, "workspacePaths": [str(mam_sandbox)]}
res = _run_guard(payload, marker_path=marker)
assert res["decision"] == "deny"
assert "run_loop.sh" in res.get("reason", "")
# Z-3: Non-mutating tools allowed during loop
@pytest.mark.parametrize("tool_name", ["run_command", "view_file", "grep_search"])
def test_z3_non_mutating_tools_allowed(mam_sandbox, tool_name):
marker = mam_sandbox / ".mam" / "loop-guard-active"
marker.parent.mkdir(parents=True, exist_ok=True)
my_pid = os.getpid()
lstart = _get_lstart(my_pid)
marker.write_text(f"pid={my_pid}\nlstart={lstart}\n")
payload = {"toolCall": {"name": tool_name}, "workspacePaths": [str(mam_sandbox)]}
res = _run_guard(payload, marker_path=marker)
assert res["decision"] == "allow"
# Z-4: Malformed input fails open
@pytest.mark.parametrize("bad_input", ["not json", "{bad: json", "", "[]", "123"])
def test_z4_malformed_input_fails_open(mam_sandbox, bad_input):
marker = mam_sandbox / ".mam" / "loop-guard-active"
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(f"pid={os.getpid()}\nlstart={_get_lstart(os.getpid())}\n")
res = _run_guard(bad_input, marker_path=marker)
assert res["decision"] == "allow"
# Z-5: Transcript signal covers gap before marker creation
def test_z5_transcript_signal(mam_sandbox):
tfile = mam_sandbox / "transcript.jsonl"
tfile.write_text('{"content": "user typed /multi-agent-mux-loop --task test"}\n')
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)], "transcriptPath": str(tfile)}
res = _run_guard(payload, marker_path=mam_sandbox / ".mam" / "nonexistent")
assert res["decision"] == "deny"
# Z-6: Matcher targets step-type names
def test_z6_hooks_json_matcher():
hooks_json = REPO_ROOT / ".agents" / "hooks.json"
assert hooks_json.exists()
data = json.loads(hooks_json.read_text())
matcher = data["mam-loop-delegation-guard"]["PreToolUse"][0]["matcher"]
assert matcher == "file_change|edit_notebook|write_blob"
assert "write_to_file" not in matcher
assert "replace_file_content" not in matcher
# Z-7: run_loop.sh source verifies marker creation and trap release
def test_z7_run_loop_marker_creation_and_trap():
run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
content = run_loop.read_text()
assert 'MAM_LOOP_MARKER=' in content
assert 'trap _mam_release_guard EXIT INT TERM HUP' in content
# Z-8: Dead PID marker is ignored
def test_z8_dead_pid_marker(mam_sandbox):
marker = mam_sandbox / ".mam" / "loop-guard-active"
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("pid=999999\nlstart=Sat Jan 1 00:00:00 2000\n")
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
res = _run_guard(payload, marker_path=marker)
assert res["decision"] == "allow"
# Z-9: delegate_job_safe restores _mam_release_guard trap
def test_z9_delegate_job_safe_restores_trap():
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
# Z-10: Reused PID with stale lstart is NOT blocked
def test_z10_reused_pid_stale_lstart(mam_sandbox):
marker = mam_sandbox / ".mam" / "loop-guard-active"
marker.parent.mkdir(parents=True, exist_ok=True)
my_pid = os.getpid()
marker.write_text(f"pid={my_pid}\nlstart=Wed Jan 1 00:00:00 1999\n")
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
res = _run_guard(payload, marker_path=marker)
assert res["decision"] == "allow"
# Z-11: Live PID with matching lstart IS blocked
def test_z11_live_pid_matching_lstart(mam_sandbox):
marker = mam_sandbox / ".mam" / "loop-guard-active"
marker.parent.mkdir(parents=True, exist_ok=True)
my_pid = os.getpid()
lstart = _get_lstart(my_pid)
marker.write_text(f"pid={my_pid}\nlstart={lstart}\n")
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
res = _run_guard(payload, marker_path=marker)
assert res["decision"] == "deny"
# Z-12: Process owned by another user (pid=1 root) is NOT blocked
def test_z12_other_user_process_does_not_block(mam_sandbox):
marker = mam_sandbox / ".mam" / "loop-guard-active"
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("pid=1\nlstart=Sat Jan 1 00:00:00 2000\n")
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
res = _run_guard(payload, marker_path=marker)
assert res["decision"] == "allow"
# Z-13: Legacy marker without lstart degrades open for other owners
def test_z13_legacy_marker_permission_error_degrades_open(mam_sandbox):
marker = mam_sandbox / ".mam" / "loop-guard-active"
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("pid=1\n")
payload = {"toolCall": {"name": "file_change"}, "workspacePaths": [str(mam_sandbox)]}
res = _run_guard(payload, marker_path=marker)
assert res["decision"] == "allow"
# Z-14: run_loop.sh records lstart in marker
def test_z14_run_loop_records_lstart(mam_sandbox):
run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
content = run_loop.read_text()
assert "_mam_lstart" in content
assert "lstart=" in content