Issue #12 - MQTT_PASSWORD was copied into every job record, which any sub-agent can read. BrokerConfig.to_registry_block no longer persists it; broker_config_from_job layers the password back from the environment (.mam.env) at connect time, so publish_event still connects from the record. _atomic_write_record scrubs broker.password from any record it writes, and `registry.py scrub-secrets [--dry-run]` migrates an existing registry. auth_token/dispatch_token stay: the agent's own publish_event needs them. Issue #8 - update.sh restored the pre-update asset_hashes.txt and version.txt over the installer's fresh ones, so version.txt named the previous commit and remove.sh backed up every pristine updated file as "locally modified". The success path now keeps the installer's stamps and only merges install_state keys the re-install did not write (gitignore_created); the failure path still restores everything verbatim. Issue #7 - the shim's new-session took the first same-cwd pane, always found the full first workspace, judged it overflow and opened another workspace per agent (4 + 1 + 1). It now walks every same-cwd workspace in pane order and takes the first with an idle pane or room in its 2xK grid; a workspace is created only when all are full (4 + 2). Tests: test_issue7_workspace_packing.py (3; the mock herdr gains an opt-in per-workspace 2x2 geometry), test_issue8_update_state.py (1, real install+update cycle), test_issue12_registry_secrets.py (4); registry CLI snapshot and help capture refreshed for the intentional scrub-secrets subcommand; registry.md documents the broker block. Refs #12 #8 #7 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
98 lines
4.3 KiB
Python
98 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Issue #12 — the broker password must not be persisted in job records.
|
|
|
|
Every sub-agent can read `.mam/jobs/<id>.json`, so `MQTT_PASSWORD` copied
|
|
there ends up in agent transcripts. The broker block now carries only
|
|
host/port/tls/username; the password is layered back from the environment
|
|
(`.mam.env`) at connect time. Records written before this rule are cleaned on
|
|
their next write and by `registry.py scrub-secrets`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKILLS = REPO_ROOT / ".agents" / "skills"
|
|
REGISTRY_PY = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "registry.py"
|
|
|
|
sys.path.insert(0, str(SKILLS))
|
|
from lib_py.delegation import mqtt_common, registry # noqa: E402
|
|
|
|
|
|
def _secure_env(monkeypatch):
|
|
monkeypatch.setenv("MQTT_BROKER", "broker.example.test")
|
|
monkeypatch.setenv("MQTT_USERNAME", "mam-user")
|
|
monkeypatch.setenv("MQTT_PASSWORD", "s3cret-broker-pass")
|
|
|
|
|
|
def test_registry_block_carries_no_password(monkeypatch):
|
|
_secure_env(monkeypatch)
|
|
block = mqtt_common.broker_config_from_env().to_registry_block()
|
|
assert "password" not in block
|
|
assert block["username"] == "mam-user" and block["host"] == "broker.example.test"
|
|
|
|
|
|
def test_registered_record_has_no_password_but_connects_with_it(mam_sandbox, monkeypatch):
|
|
_secure_env(monkeypatch)
|
|
rd = str(mam_sandbox / ".mam" / "jobs")
|
|
job_id = registry.register_job("issue 12", "claude-code", "herdr:claude", registry_dir=rd)
|
|
|
|
record = json.loads((Path(rd) / f"{job_id}.json").read_text())
|
|
assert "password" not in record["broker"], record["broker"]
|
|
assert "s3cret-broker-pass" not in json.dumps(record)
|
|
# The secure broker still gets its per-job HMAC key (needed by the agent's
|
|
# own publish_event to sign; documented as remaining in the record).
|
|
assert record["auth_token"]
|
|
|
|
cfg = mqtt_common.broker_config_from_job(record)
|
|
assert cfg.password == "s3cret-broker-pass", "password must come back from the environment"
|
|
assert cfg.username == "mam-user"
|
|
|
|
|
|
def test_legacy_record_is_scrubbed_on_next_write(mam_sandbox, monkeypatch):
|
|
_secure_env(monkeypatch)
|
|
rd = str(mam_sandbox / ".mam" / "jobs")
|
|
job_id = registry.register_job("legacy", "claude-code", "herdr:claude", registry_dir=rd)
|
|
path = Path(rd) / f"{job_id}.json"
|
|
record = json.loads(path.read_text())
|
|
record["broker"]["password"] = "leaked-earlier"
|
|
path.write_text(json.dumps(record))
|
|
|
|
registry.update_status(job_id, rd, "running")
|
|
|
|
after = json.loads(path.read_text())
|
|
assert after["status"] == "running"
|
|
assert "password" not in after["broker"]
|
|
|
|
|
|
def test_scrub_secrets_cli_migrates_a_registry(mam_sandbox, monkeypatch):
|
|
_secure_env(monkeypatch)
|
|
rd = str(mam_sandbox / ".mam" / "jobs")
|
|
dirty = registry.register_job("dirty", "claude-code", "herdr:claude", registry_dir=rd)
|
|
clean = registry.register_job("clean", "claude-code", "herdr:claude", registry_dir=rd)
|
|
dirty_path = Path(rd) / f"{dirty}.json"
|
|
rec = json.loads(dirty_path.read_text())
|
|
rec["broker"]["password"] = "leaked-earlier"
|
|
dirty_path.write_text(json.dumps(rec))
|
|
clean_before = (Path(rd) / f"{clean}.json").read_text()
|
|
|
|
env = dict(os.environ, PYTHONPATH=f"{SKILLS}:{os.environ.get('PYTHONPATH', '')}")
|
|
dry = subprocess.run([sys.executable, str(REGISTRY_PY), "--registry-dir", rd, "scrub-secrets", "--dry-run"],
|
|
capture_output=True, text=True, env=env, cwd=str(mam_sandbox))
|
|
assert dry.returncode == 0, dry.stderr
|
|
assert f"would scrub 1 record(s)" in dry.stdout and dirty in dry.stdout
|
|
assert "leaked-earlier" in dirty_path.read_text(), "--dry-run must not write"
|
|
|
|
res = subprocess.run([sys.executable, str(REGISTRY_PY), "--registry-dir", rd, "scrub-secrets"],
|
|
capture_output=True, text=True, env=env, cwd=str(mam_sandbox))
|
|
assert res.returncode == 0, res.stderr
|
|
assert "scrubbed 1 record(s)" in res.stdout
|
|
scrubbed = json.loads(dirty_path.read_text())
|
|
assert "password" not in scrubbed["broker"]
|
|
assert scrubbed["prompt"] == "dirty" and scrubbed["auth_token"] == rec["auth_token"]
|
|
assert (Path(rd) / f"{clean}.json").read_text() == clean_before, "untouched records are not rewritten"
|