diff --git a/.agents/skills/lib_py/delegation/registry.py b/.agents/skills/lib_py/delegation/registry.py index a8c2706..189ecb8 100755 --- a/.agents/skills/lib_py/delegation/registry.py +++ b/.agents/skills/lib_py/delegation/registry.py @@ -345,13 +345,19 @@ def _build_parser() -> argparse.ArgumentParser: def _cmd_scrub_secrets(args) -> int: """Issue #12: strip broker.password from records written before - to_registry_block stopped persisting it. Every other field is kept.""" + to_registry_block stopped persisting it. Every other field is kept. + + Only a stored value counts. The old rule wrote `"password": null` even + for an unauthenticated broker; that empty key is no secret, and + _atomic_write_record drops it on the record's next write anyway, so + reporting or rewriting it here would list phantom secrets and hold the + registry lock over every record in a PoC registry.""" rd = args.registry_dir found = [] with registry_lock(rd): for record in list(_iter_records(rd)): broker = record.get("broker") - if not (isinstance(broker, dict) and "password" in broker): + if not (isinstance(broker, dict) and broker.get("password")): continue found.append(record.get("job_id", "?")) if not args.dry_run: diff --git a/.agents/skills/multi-agent-mux-delegate-job/registry.md b/.agents/skills/multi-agent-mux-delegate-job/registry.md index cbdafef..907cb04 100644 --- a/.agents/skills/multi-agent-mux-delegate-job/registry.md +++ b/.agents/skills/multi-agent-mux-delegate-job/registry.md @@ -57,8 +57,9 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py) overrides toggles like `MQTT_TLS`). It never carries the password (Issue #12): every sub-agent can read the record, so `MQTT_PASSWORD` is layered back from the environment (`.mam.env`) at connect time. Records written before that rule - are cleaned on their next write; `registry.py scrub-secrets [--dry-run]` cleans - a whole registry at once. `auth_token` and `dispatch_token` stay in the record + are cleaned on their next write; `registry.py scrub-secrets [--dry-run]` strips + every stored password from a whole registry at once (an empty `null` key left + by the old rule is not a secret; it goes on the record's next write). `auth_token` and `dispatch_token` stay in the record because the agent's own `publish_event` needs them to sign and to pin its attempt. - `topic_prefix` β†’ the events topic is `/events`. diff --git a/VERSIONS.md b/VERSIONS.md index 715e74d..18e1a6e 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -43,7 +43,7 @@ #### 🌟 μ£Όμš” κ°œμ„  사항 1. **작 λ ˆμ½”λ“œμ˜ 브둜컀 λΉ„λ°€λ²ˆν˜Έ 제거 (Issue #12, `mqtt_common.BrokerConfig.to_registry_block`, `registry.py`)**: `to_registry_block` 이 더 이상 `password` λ₯Ό λ ˆμ½”λ“œμ˜ `broker` 블둝에 λ„£μ§€ μ•ŠλŠ”λ‹€. `broker_config_from_job` 은 λ ˆμ½”λ“œ 블둝을 ν™˜κ²½ μ„€μ • μœ„μ— κ²ΉμΉ˜λ―€λ‘œ 접속 μ‹œ λΉ„λ°€λ²ˆν˜ΈλŠ” `MQTT_PASSWORD`(`.mam.env`)μ—μ„œ μ±„μ›Œμ§€κ³ , `publish_event` λŠ” μ—¬μ „νžˆ λ ˆμ½”λ“œλ§ŒμœΌλ‘œ μ ‘μ†ν•œλ‹€. `_atomic_write_record` λŠ” μ“°λŠ” λͺ¨λ“  λ ˆμ½”λ“œμ—μ„œ `broker.password` 만 μ§€μš°λ―€λ‘œ(λ‹€λ₯Έ ν•„λ“œλŠ” κ·ΈλŒ€λ‘œ) 이전 κ·œμΉ™μœΌλ‘œ 쓰인 λ ˆμ½”λ“œλŠ” λ‹€μŒ μ“°κΈ° λ•Œ μ •λ¦¬λœλ‹€. `auth_token`Β·`dispatch_token` 은 μ—μ΄μ „νŠΈ μžμ‹ μ˜ `publish_event` κ°€ μ„œλͺ…κ³Ό attempt 고정에 μ“°λ―€λ‘œ λ ˆμ½”λ“œμ— 남긴닀. -2. **`registry.py scrub-secrets [--dry-run]` λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ (Issue #12)**: λ ˆμ§€μŠ€νŠΈλ¦¬ lock μ•„λž˜μ—μ„œ `broker.password` λ₯Ό κ°€μ§„ λ ˆμ½”λ“œλ§Œ 골라 λΉ„λ°€λ²ˆν˜Έλ₯Ό μ§€μš°κ³  μž¬κΈ°λ‘ν•œλ‹€. `--dry-run` 은 λŒ€μƒ 작 λͺ©λ‘λ§Œ 좜λ ₯ν•˜κ³  아무것도 λ°”κΎΈμ§€ μ•ŠλŠ”λ‹€. `registry.md` κ°€ 브둜컀 블둝 κ·œμΉ™(λΉ„λ°€λ²ˆν˜Έ λΉ„μ €μž₯, ν™˜κ²½μ—μ„œ 볡원, 토큰 μœ μ§€ 이유)을 λ¬Έμ„œν™”ν•œλ‹€. +2. **`registry.py scrub-secrets [--dry-run]` λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ (Issue #12)**: λ ˆμ§€μŠ€νŠΈλ¦¬ lock μ•„λž˜μ—μ„œ **λΉ„λ°€λ²ˆν˜Έ 값이 μ‹€μ œλ‘œ μ €μž₯된** λ ˆμ½”λ“œλ§Œ 골라 μ§€μš°κ³  μž¬κΈ°λ‘ν•œλ‹€. 이전 κ·œμΉ™μ΄ 무인증 λͺ¨λ“œμ—μ„œλ„ 남긴 `"password": null` ν‚€λŠ” 비밀이 μ•„λ‹ˆλ―€λ‘œ λŒ€μƒλ„ 보고도 μ•„λ‹ˆλ©°, κ·Έ λ ˆμ½”λ“œμ˜ λ‹€μŒ μ“°κΈ° λ•Œ `_atomic_write_record` κ°€ μ •λ¦¬ν•œλ‹€. `--dry-run` 은 λŒ€μƒ 작 λͺ©λ‘λ§Œ 좜λ ₯ν•˜κ³  아무것도 λ°”κΎΈμ§€ μ•ŠλŠ”λ‹€. `registry.md` κ°€ 브둜컀 블둝 κ·œμΉ™(λΉ„λ°€λ²ˆν˜Έ λΉ„μ €μž₯, ν™˜κ²½μ—μ„œ 볡원, 토큰 μœ μ§€ 이유)을 λ¬Έμ„œν™”ν•œλ‹€. 3. **μ—…λ°μ΄νŠΈ ν›„ μ„€μΉ˜ μŠ€νƒ¬ν”„ 보쑴 (Issue #8, `deploy/update.sh`)**: 성곡 κ²½λ‘œκ°€ μ—…λ°μ΄νŠΈ μ „ `asset_hashes.txt`Β·`version.txt` λ₯Ό λ˜λŒλ¦¬μ§€ μ•ŠλŠ”λ‹€. κ·Έ 볡원 탓에 `version.txt` κ°€ 이전 컀밋을 가리킀고 `remove.sh` κ°€ κ°±μ‹ λœ 원본 νŒŒμΌμ„ "둜컬 μˆ˜μ •"으둜 μ˜€νŒν•΄ λ°±μ—…ν•˜λ˜ λ¬Έμ œκ°€ 사라진닀. `install_state` λŠ” μž¬μ„€μΉ˜κ°€ μ“°μ§€ μ•Šμ€ ν‚€(예: `gitignore_created`)만 `key=value` 쀄 λ‹¨μœ„λ‘œ λ³‘ν•©ν•˜λ©°(이미 μžˆλŠ” ν‚€λŠ” κ±΄λ„ˆλœ€, λ§ˆμ§€λ§‰ 쀄에 κ°œν–‰μ΄ 없어도 처리), μ‹€νŒ¨ 경둜(`restore_on_failure`)λŠ” μ’…μ „λŒ€λ‘œ μ„Έ νŒŒμΌμ„ λͺ¨λ‘ κ·ΈλŒ€λ‘œ λ³΅μ›ν•œλ‹€. 4. **같은 cwd ν˜•μ œ μ›Œν¬μŠ€νŽ˜μ΄μŠ€ νŒ¨ν‚Ή (Issue #7, `lib.sh` μ‰Ό `new-session`)**: 첫 번째둜 μΌμΉ˜ν•œ pane 의 μ›Œν¬μŠ€νŽ˜μ΄μŠ€λ§Œ 보던 탓에 가득 μ°¬ 첫 μ›Œν¬μŠ€νŽ˜μ΄μŠ€λ₯Ό 맀번 overflow 둜 νŒμ •ν•΄ μ—μ΄μ „νŠΈλ§ˆλ‹€ μƒˆ μ›Œν¬μŠ€νŽ˜μ΄μŠ€λ₯Ό μ—΄λ˜ 문제(4 + 1 + 1)λ₯Ό κ³ μ³€λ‹€. 이제 같은 cwd 의 λͺ¨λ“  μ›Œν¬μŠ€νŽ˜μ΄μŠ€λ₯Ό pane μˆœμ„œλŒ€λ‘œ μˆœνšŒν•΄ idle pane 이 μžˆκ±°λ‚˜ 2xK κ·Έλ¦¬λ“œμ— μ—¬μœ κ°€ μžˆλŠ” 첫 μ›Œν¬μŠ€νŽ˜μ΄μŠ€μ— λ°°μΉ˜ν•˜κ³ (overflow 인 ν›„λ³΄λŠ” λ‹€μŒ ν›„λ³΄λ‘œ λ„˜μ–΄κ°), λͺ¨λ‘ 가득 찼을 λ•Œλ§Œ μƒˆ μ›Œν¬μŠ€νŽ˜μ΄μŠ€λ₯Ό λ§Œλ“ λ‹€(4 + 2). diff --git a/tests/test_issue12_registry_secrets.py b/tests/test_issue12_registry_secrets.py index 7bb5cdc..16142f5 100644 --- a/tests/test_issue12_registry_secrets.py +++ b/tests/test_issue12_registry_secrets.py @@ -79,12 +79,22 @@ def test_scrub_secrets_cli_migrates_a_registry(mam_sandbox, monkeypatch): rec["broker"]["password"] = "leaked-earlier" dirty_path.write_text(json.dumps(rec)) clean_before = (Path(rd) / f"{clean}.json").read_text() + # What the old to_registry_block wrote for every unauthenticated job: the key + # with a null value. No secret, so the migration must neither report nor + # rewrite it (a real PoC registry held ~900 of these and no password at all). + empty = registry.register_job("empty-key", "claude-code", "herdr:claude", registry_dir=rd) + empty_path = Path(rd) / f"{empty}.json" + rec_empty = json.loads(empty_path.read_text()) + rec_empty["broker"]["password"] = None + empty_path.write_text(json.dumps(rec_empty)) + empty_before = empty_path.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 empty not in dry.stdout, "an empty password key is not a stored secret" 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"], @@ -95,3 +105,4 @@ def test_scrub_secrets_cli_migrates_a_registry(mam_sandbox, monkeypatch): 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" + assert empty_path.read_text() == empty_before, "a null password key is left for the record's next write"