#!/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