feat(o3): implement Invocation-Aware Scoped Guard for orchestrator role scoping (100% PASS)
This commit is contained in:
@@ -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시간) 이상으로 항상 넉넉히 설정해야 합니다. 모니터가 작업 완료 전에 유휴 감지로 조기 자동 종료되어 백그라운드 태스크 관리를 소실하는 문제를 방지하기 위함입니다.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+136
@@ -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
|
||||
|
||||
@@ -68,3 +68,9 @@ 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.
|
||||
|
||||
## 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
@@ -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>`) 도출 체계를 구축했습니다.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user