test(issue-20): session guard for new entries in the recorded control planes' .mam/jobs and .mam/delegate_job_logs
Watches this checkout's .mam and the ones the recorded binding named. A new entry is a leak when it is an audit log without a registry record, or when its agent_session is missing or not a registered session; live delegations write the record first and target a registered session. Reports, never deletes. The #19 guard's docstring pointed at a #15 session guard that never existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sdga4A8i1iSc8DaaW9iKU2
This commit is contained in:
1 parent
7bd695c209
commit
c5844a8e12
3 files changed
+282
-1
No files matched your search
@@ -0,0 +1,157 @@
|
||||
"""Issue #20 - helpers for the session guard over real control planes.
|
||||
|
||||
A pytest process can reach two kinds of `.mam`: this checkout's own, and the
|
||||
one the agent pane's control-plane binding named before conftest's
|
||||
pytest_configure dropped it (a worktree creator's pane carries
|
||||
WORKSPACE_ROOT, AGENT_SESSIONS_YAML, MAM_JOBS_DIR and DELEGATE_JOB_LOGS_DIR,
|
||||
all at the live main checkout). The session guard snapshots their job
|
||||
registries and audit-log roots and, at the end, reports the new entries no
|
||||
live delegation explains. Pure functions, unit-tested in
|
||||
test_issue20_state_isolation.py (S4, S5); the name does not start with
|
||||
`test_`, so pytest never collects it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
SESSION_PREFIXES = ("herdr:", "remote:")
|
||||
# Top-level items of `herdr_sessions:` as atomic_dump_yaml writes them
|
||||
# (`- name: <session>` at column 0); indented `- name:` lines belong to nested
|
||||
# lists such as mcp_attachments. A regex keeps the guard free of PyYAML.
|
||||
_SESSION_LINE = re.compile(r"""^- name:\s*['"]?([^'"\s]+)""")
|
||||
|
||||
|
||||
def _listdir(path):
|
||||
try:
|
||||
return os.listdir(path)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _load(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def watch_roots(repo_root, recorded):
|
||||
"""`.mam` directories to watch, realpath-deduplicated, in order: this
|
||||
checkout's own (always, so a leak that creates it is still seen), then
|
||||
those the recorded binding names - `$WORKSPACE_ROOT/.mam` and the parents
|
||||
of MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR - when they exist."""
|
||||
roots = [os.path.realpath(os.path.join(str(repo_root), ".mam"))]
|
||||
candidates = []
|
||||
if recorded.get("WORKSPACE_ROOT"):
|
||||
candidates.append(os.path.join(recorded["WORKSPACE_ROOT"], ".mam"))
|
||||
for var in ("MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR"):
|
||||
if recorded.get(var):
|
||||
candidates.append(os.path.dirname(os.path.abspath(recorded[var])))
|
||||
for path in candidates:
|
||||
real = os.path.realpath(path)
|
||||
if os.path.isdir(real) and real not in roots:
|
||||
roots.append(real)
|
||||
return roots
|
||||
|
||||
|
||||
def state_dirs(roots, recorded):
|
||||
"""(registry dirs, audit-log dirs): `<root>/jobs` and
|
||||
`<root>/delegate_job_logs` of every root, plus a recorded MAM_JOBS_DIR /
|
||||
DELEGATE_JOB_LOGS_DIR that is not one of those."""
|
||||
jobs = [os.path.join(r, "jobs") for r in roots]
|
||||
logs = [os.path.join(r, "delegate_job_logs") for r in roots]
|
||||
for var, dirs in (("MAM_JOBS_DIR", jobs), ("DELEGATE_JOB_LOGS_DIR", logs)):
|
||||
if recorded.get(var):
|
||||
real = os.path.realpath(recorded[var])
|
||||
if os.path.isdir(real) and real not in dirs:
|
||||
dirs.append(real)
|
||||
return jobs, logs
|
||||
|
||||
|
||||
def snapshot(roots, recorded):
|
||||
"""Registry records (`*.json`) and audit-log entries per watched dir; a
|
||||
missing dir counts as empty."""
|
||||
jobs, logs = state_dirs(roots, recorded)
|
||||
return {
|
||||
"jobs": {d: {n for n in _listdir(d) if n.endswith(".json")} for d in jobs},
|
||||
"logs": {d: set(_listdir(d)) for d in logs},
|
||||
}
|
||||
|
||||
|
||||
def session_names(yaml_path):
|
||||
"""Session names registered in an agent-sessions.yaml; empty if unreadable."""
|
||||
try:
|
||||
with open(yaml_path, encoding="utf-8", errors="replace") as fh:
|
||||
return {m.group(1) for m in map(_SESSION_LINE.match, fh) if m}
|
||||
except OSError:
|
||||
return set()
|
||||
|
||||
|
||||
def _bare(session):
|
||||
for prefix in SESSION_PREFIXES:
|
||||
if session.startswith(prefix):
|
||||
return session[len(prefix):]
|
||||
return session
|
||||
|
||||
|
||||
def leaks(roots, before, recorded):
|
||||
"""New entries since `before` that no live delegation explains.
|
||||
|
||||
A real delegation writes its registry record before its audit log and
|
||||
targets a registered session (registry.register_job), so a new entry is a
|
||||
leak when
|
||||
(a) it is an audit log whose job has no record in any watched registry, or
|
||||
(b) its record's agent_session is missing or names no session in the
|
||||
watched agent-sessions.yaml files.
|
||||
Returns [{"path", "reason", "agent_session", "prompt"}] sorted by path."""
|
||||
now = snapshot(roots, recorded)
|
||||
yamls = [os.path.join(r, "agent-sessions.yaml") for r in roots]
|
||||
if recorded.get("AGENT_SESSIONS_YAML"):
|
||||
yamls.append(recorded["AGENT_SESSIONS_YAML"])
|
||||
known = set()
|
||||
for path in yamls:
|
||||
known |= session_names(path)
|
||||
records = {}
|
||||
for d, names in now["jobs"].items():
|
||||
for name in names:
|
||||
records.setdefault(name[:-len(".json")], os.path.join(d, name))
|
||||
|
||||
found = []
|
||||
|
||||
def judge(path, meta, reason=None):
|
||||
session = meta.get("agent_session")
|
||||
if reason is None:
|
||||
if not session:
|
||||
reason = "no agent_session"
|
||||
elif _bare(session) not in known:
|
||||
reason = "agent_session names no registered session"
|
||||
else:
|
||||
return
|
||||
found.append({"path": path, "reason": reason, "agent_session": session,
|
||||
"prompt": str(meta.get("prompt", ""))[:60]})
|
||||
|
||||
for d, names in now["jobs"].items():
|
||||
for name in names - before["jobs"].get(d, set()):
|
||||
path = os.path.join(d, name)
|
||||
judge(path, _load(path))
|
||||
for d, names in now["logs"].items():
|
||||
for name in names - before["logs"].get(d, set()):
|
||||
path = os.path.join(d, name)
|
||||
if name in records:
|
||||
judge(path, _load(records[name]))
|
||||
else:
|
||||
judge(path, _load(os.path.join(path, "meta.json")), "audit log without a registry record")
|
||||
return sorted(found, key=lambda f: f["path"])
|
||||
|
||||
|
||||
def describe(found):
|
||||
"""Failure message for the session guard."""
|
||||
lines = ["Issue #20: {} entr{} made during this session in a real control plane "
|
||||
"(left in place; check and remove by hand):".format(len(found), "y" if len(found) == 1 else "ies")]
|
||||
lines += [" {path} [{reason}] agent_session={agent_session!r} prompt={prompt!r}".format(**f)
|
||||
for f in found]
|
||||
return "\n".join(lines)
|
||||
Reference in new issue
Block a user