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
|
||||
|
||||
Reference in New Issue
Block a user