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
+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