- Replace '--target-agent' with mandatory '--creator <session>' flag
- Add explicit rejection error for legacy '--target-agent'
- Add '--planner <session>' flag with fail-fast check when '--plan' is missing
- Implement 2-branch session validation ('is not registered' vs 'is not running')
- Update in-repo references in hooks, SKILL.md, INSTALL.md, and tests
- Add tests/test_loop_cli.py with 9 unit/contract tests
137 lines
5.0 KiB
Bash
Executable File
137 lines
5.0 KiB
Bash
Executable File
#!/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 "
|
|
"--creator <session> --task <goal>. "
|
|
"See .agents/MULTI_AGENT_RULES.md #3.2 (Invocation-Aware Scoped Guard).")
|
|
PY
|