From 1f8622e077606c75ce724db8aefcaa39f0a9bc22 Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 7 Aug 2026 10:09:14 +0900 Subject: [PATCH] feat(o3): implement Invocation-Aware Scoped Guard for orchestrator role scoping (100% PASS) --- .agents/MULTI_AGENT_RULES.ko.md | 10 + .agents/MULTI_AGENT_RULES.md | 10 + .agents/hooks.json | 12 + .agents/hooks/loop_delegation_guard.sh | 136 +++++++++++ .../scripts/create_session.sh | 5 +- .agents/skills/multi-agent-mux-loop/SKILL.md | 1 + .../multi-agent-mux-loop/scripts/run_loop.sh | 11 +- .../scripts/reconcile.sh | 4 +- .../scripts/resume_session.sh | 5 +- .../scripts/stop_session.sh | 5 +- AGENTS.md | 8 +- IMPROVEMENTS.md | 27 ++- tests/test_o3_scoped_guard.py | 214 ++++++++++++++++++ 13 files changed, 428 insertions(+), 20 deletions(-) create mode 100644 .agents/hooks.json create mode 100755 .agents/hooks/loop_delegation_guard.sh create mode 100644 tests/test_o3_scoped_guard.py diff --git a/.agents/MULTI_AGENT_RULES.ko.md b/.agents/MULTI_AGENT_RULES.ko.md index b8ec09a..d7dbd5d 100644 --- a/.agents/MULTI_AGENT_RULES.ko.md +++ b/.agents/MULTI_AGENT_RULES.ko.md @@ -150,6 +150,16 @@ TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해 - *버전 관리 이관*: 버전 관리가 필요한 주요 산출물(최종 설계 계획, 최종 리뷰 보고서, 보안 감사 리포트 등)은 gitignore 대상인 `.mam/` 하위가 아닌, 버전 관리 대상 경로(구체적으로 `.agents/reports//` 또는 `docs/reports/` 등)로 명시적으로 복사하여 이관 보존해야 합니다. - **디스크 정리 및 보존 정책 계약 (Cleanup & Retention)**: `.mam/jobs//` 및 `.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시간) 이상으로 항상 넉넉히 설정해야 합니다. 모니터가 작업 완료 전에 유휴 감지로 조기 자동 종료되어 백그라운드 태스크 관리를 소실하는 문제를 방지하기 위함입니다. diff --git a/.agents/MULTI_AGENT_RULES.md b/.agents/MULTI_AGENT_RULES.md index dc3c8b6..f59ed6e 100644 --- a/.agents/MULTI_AGENT_RULES.md +++ b/.agents/MULTI_AGENT_RULES.md @@ -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//` or `docs/reports/`). - **Cleanup & Retention Contract**: Files under `.mam/jobs//` 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. diff --git a/.agents/hooks.json b/.agents/hooks.json new file mode 100644 index 0000000..4f726b6 --- /dev/null +++ b/.agents/hooks.json @@ -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 } + ] + } + ] + } +} diff --git a/.agents/hooks/loop_delegation_guard.sh b/.agents/hooks/loop_delegation_guard.sh new file mode 100755 index 0000000..52d9d97 --- /dev/null +++ b/.agents/hooks/loop_delegation_guard.sh @@ -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 --task . " + "See .agents/MULTI_AGENT_RULES.md #3.2 (Invocation-Aware Scoped Guard).") +PY diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index bb05a95..b77a073 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -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 < **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`)만을 단독으로 사용하여 자동으로 오케스트레이션합니다. 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 ddb3ef9..a4d2591 100644 --- a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/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 } diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index dca904e..e1b9438 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -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 diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 80fe46a..6bbb6e3 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -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 </dev/null || pwd)/lib.sh" +[ -f "$_lib_sh" ] || _lib_sh="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh" +source "$_lib_sh" usage() { cat <-`) 도출 체계를 구축했습니다. diff --git a/tests/test_o3_scoped_guard.py b/tests/test_o3_scoped_guard.py new file mode 100644 index 0000000..ab52217 --- /dev/null +++ b/tests/test_o3_scoped_guard.py @@ -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