From f6bff7f7e4de476a243d061397faea29c7c367be Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 18 Aug 2026 12:33:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(keygen):=20pmoves-keygen=20integration=20?= =?UTF-8?q?=E2=80=94=20auto-populate=20signing-card=20ml=20halves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the pmoves-keygen submodule (fork of charmbracelet/keygen) and a keygen_cards wrapper that turns pending-ml signing identity cards into 5x5 cards: - generate --agent : Ed25519 keypair (passphrase via env, never CLI), patches the card's ssh_fingerprint + ssh_allowed_signers_line in place with comment-preserving ruamel (width=4096 so the signers line never wraps) - ssh-auth --name : fleet-access keypair with secrets-funnel *_FILE hint - audit: lists cards still missing their ml SSH half Passphrases resolve from --passphrase-env or KEYGEN_PASSPHRASE. Private keys land in pmoves/chit/keys/ (gitignored). Refuses to overwrite existing keys. Fingerprint output cross-verified byte-identical against ssh-keygen; passphrase enforcement verified. Fork PR powermoves/pmoves-keygen#1 adds the keygen-cli shim this wraps; the gitlink pins its branch tip until it lands on master, then repoint in a follow-up. Testing: pytest tests/tools/test_keygen_cards.py — 6 passed; live generate+revert round-trip on the crush card validated YAML integrity. 💘 Generated with Crush --- .gitmodules | 3 + pmoves-keygen | 1 + pmoves/.gitignore | 1 + pmoves/tests/tools/test_keygen_cards.py | 94 +++++++++ pmoves/tools/keygen_cards.py | 242 ++++++++++++++++++++++++ 5 files changed, 341 insertions(+) create mode 160000 pmoves-keygen create mode 100644 pmoves/tests/tools/test_keygen_cards.py create mode 100644 pmoves/tools/keygen_cards.py diff --git a/.gitmodules b/.gitmodules index ea0c050ebc..5fe912e840 100644 --- a/.gitmodules +++ b/.gitmodules @@ -428,3 +428,6 @@ path = PMOVES-agents.md url = https://github.com/POWERFULMOVES/PMOVES-agents.md.git branch = main +[submodule "pmoves-keygen"] + path = pmoves-keygen + url = https://github.com/POWERFULMOVES/pmoves-keygen.git diff --git a/pmoves-keygen b/pmoves-keygen new file mode 160000 index 0000000000..36ef04163a --- /dev/null +++ b/pmoves-keygen @@ -0,0 +1 @@ +Subproject commit 36ef04163a000da2f993899c1357063d193d7a4c diff --git a/pmoves/.gitignore b/pmoves/.gitignore index 9245fe80f6..753ac3caa7 100644 --- a/pmoves/.gitignore +++ b/pmoves/.gitignore @@ -71,3 +71,4 @@ data/spark/ # Local audio artifacts: smoke tests (kokoro-smoke / omnivoice-smoke sample # WAVs) + voice_cast_on_sign.py generated audio (Phase 0 expressive voice). /out/ +chit/keys/ diff --git a/pmoves/tests/tools/test_keygen_cards.py b/pmoves/tests/tools/test_keygen_cards.py new file mode 100644 index 0000000000..242652e0c2 --- /dev/null +++ b/pmoves/tests/tools/test_keygen_cards.py @@ -0,0 +1,94 @@ +"""Unit tests for pmoves.tools.keygen_cards (no real key generation).""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pytest +import yaml + +from pmoves.tools import keygen_cards as kc + +CARD_SAMPLE = """\ +# 5x5 Signing Identity Cards — test fixture +schema_version: "1.0.0" +generated: "2026-04-26" +cards: + - card_id: "00000000-0000-4000-8000-000000000007" + issued_at: "2026-04-26T14:06:00Z" + active: true + ml: + primary_method: github-app + github_app_installation_id: null + h: + agent_id: "crush" + display_name: "Crush" + role: agent +""" + + +@pytest.fixture() +def cards_file(tmp_path, monkeypatch): + p = tmp_path / "signing_identity_cards.yaml" + p.write_text(CARD_SAMPLE) + monkeypatch.setattr(kc, "CARDS_PATH", p) + return p + + +def _args(**kw): + base = {"passphrase_env": None} + base.update(kw) + return argparse.Namespace(**base) + + +class TestAudit: + def test_audit_reports_pending_ml(self, cards_file, capsys): + assert kc.cmd_audit(_args()) == 0 + out = capsys.readouterr().out + assert "pending-ml (1): crush" in out + + def test_audit_reports_complete(self, cards_file, capsys): + data = yaml.safe_load(cards_file.read_text()) + data["cards"][0]["ml"]["ssh_fingerprint"] = "SHA256:x" + data["cards"][0]["ml"]["ssh_allowed_signers_line"] = "crush ssh-ed25519 AAA x" + cards_file.write_text(yaml.safe_dump(data)) + assert kc.cmd_audit(_args()) == 0 + assert "complete (1): crush" in capsys.readouterr().out + + +class TestGenerate: + def test_unknown_agent_errors(self, cards_file): + assert kc.cmd_generate(_args(agent="nope", dry_run=True)) == 1 + + def test_existing_key_refused(self, cards_file, tmp_path, monkeypatch): + monkeypatch.setattr(kc, "DEFAULT_KEY_DIR", tmp_path) + (tmp_path / "crush-signing").write_text("x") + assert kc.cmd_generate(_args(agent="crush", dry_run=True)) == 1 + + +class TestParsing: + def test_cli_output_parsed(self): + # structure check: the parse loop used in generate_key + sample = ( + "authorized_key=ssh-ed25519 AAAAC3 test\n" + "fingerprint=SHA256:abc\n" + "key_type=ed25519\n" + ) + fields = {} + for line in sample.splitlines(): + if "=" in line: + key, value = line.split("=", 1) + fields[key.strip()] = value.strip() + assert fields["fingerprint"] == "SHA256:abc" + assert fields["authorized_key"] == "ssh-ed25519 AAAAC3 test" + + +class TestRuwarp: + def test_no_wrap_width_set(self): + # the wrapper must configure ruamel width so the long + # allowed-signers line never wraps (invalid-YAML regression) + import inspect + + src = inspect.getsource(kc.cmd_generate) + assert "yaml.width = 4096" in src.replace(" ", "").replace("yaml.width=4096", "yaml.width = 4096") or "width = 4096" in src diff --git a/pmoves/tools/keygen_cards.py b/pmoves/tools/keygen_cards.py new file mode 100644 index 0000000000..7373ee777e --- /dev/null +++ b/pmoves/tools/keygen_cards.py @@ -0,0 +1,242 @@ +"""Signing-card key generation backed by pmoves-keygen (charmbracelet fork). + +Generates Ed25519 SSH key pairs and auto-populates the machine-loadable +(ml) halves of pmoves/config/signing_identity_cards.yaml: + + ml: + primary_method: ssh + ssh_fingerprint: "SHA256:..." + ssh_allowed_signers_line: " ssh-ed25519 AAAA... pmoves@pmoves.ai" + +Also supports a plain SSH-auth mode that emits fleet-access keypairs via +the *_FILE secrets-funnel pattern. + +Passphrases are resolved from (in order): + 1. --passphrase-env VAR (recommended; e.g. CHIT_SIGNING_KEY_PASSPHRASE) + 2. KEYGEN_PASSPHRASE +Never a CLI argument, so they stay out of shell history and process +listings. + +Usage: + python3 -m pmoves.tools.keygen_cards generate --agent crush + python3 -m pmoves.tools.keygen_cards generate --agent hermes --dry-run + python3 -m pmoves.tools.keygen_cards ssh-auth --name jetson-1 + python3 -m pmoves.tools.keygen_cards audit +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[2] +CARDS_PATH = REPO_ROOT / "pmoves" / "config" / "signing_identity_cards.yaml" +KEYGEN_REPO = REPO_ROOT / "pmoves-keygen" +DEFAULT_KEY_DIR = REPO_ROOT / "pmoves" / "chit" / "keys" + +COMMENT_SUFFIX = "pmoves@pmoves.ai" + + +@dataclass +class KeyArtifacts: + authorized_key: str # "ssh-ed25519 AAAA... name" + fingerprint: str # "SHA256:..." + key_type: str + private_key_path: Path + public_key_path: Path + + +def _resolve_cli() -> str: + """Locate or build the keygen-cli binary from the pmoves-keygen submodule.""" + binary = KEYGEN_REPO / "keygen-cli" + if not binary.exists(): + subprocess.run( + ["go", "build", "-o", str(binary), "./cmd/keygen-cli"], + cwd=KEYGEN_REPO, + check=True, + capture_output=True, + ) + return str(binary) + + +def _passphrase_from(args: argparse.Namespace) -> str | None: + if args.passphrase_env: + return os.environ.get(args.passphrase_env) or None + return os.environ.get("KEYGEN_PASSPHRASE") or None + + +def generate_key(path: Path, args: argparse.Namespace) -> KeyArtifacts: + """Generate a keypair at path via keygen-cli, returning parsed artifacts.""" + env = dict(os.environ) + passphrase = _passphrase_from(args) + if passphrase: + env["KEYGEN_PASSPHRASE"] = passphrase + + result = subprocess.run( + [_resolve_cli(), str(path)], + env=env, + capture_output=True, + text=True, + check=True, + ) + fields = {} + for line in result.stdout.splitlines(): + if "=" in line: + key, value = line.split("=", 1) + fields[key.strip()] = value.strip() + + return KeyArtifacts( + authorized_key=fields["authorized_key"], + fingerprint=fields["fingerprint"], + key_type=fields["key_type"], + private_key_path=path, + public_key_path=Path(str(path) + ".pub"), + ) + + +def _load_cards() -> tuple[Any, str]: + """Minimal YAML handling with comment preservation via ruamel if available.""" + try: + from ruamel.yaml import YAML + + yaml = YAML() + yaml.preserve_quotes = True + data = yaml.load(CARDS_PATH.read_text()) + return data, "ruamel" + except ImportError: + import yaml + + data = yaml.safe_load(CARDS_PATH.read_text()) + return data, "plain" + + +def cmd_generate(args: argparse.Namespace) -> int: + data, mode = _load_cards() + cards = data.get("cards", []) + card = next((c for c in cards if c.get("h", {}).get("agent_id") == args.agent), None) + if card is None: + print(f"error: no signing card with agent_id '{args.agent}'", file=sys.stderr) + print( + f" known: {', '.join(sorted(c.get('h', {}).get('agent_id', '?') for c in cards))}", + file=sys.stderr, + ) + return 1 + + DEFAULT_KEY_DIR.mkdir(parents=True, exist_ok=True) + key_path = DEFAULT_KEY_DIR / f"{args.agent}-signing" + if key_path.exists(): + print(f"error: key already exists at {key_path} (refusing to overwrite)", file=sys.stderr) + return 1 + + artifacts = generate_key(key_path, args) + + # Rebuild the allowed-signers line with the card's agent principal and + # the PMOVES comment, per the 5x5 signing-card contract. + parts = artifacts.authorized_key.split() + keytype, blob = parts[0], parts[1] + allowed_signers = f"{args.agent} {keytype} {blob} {COMMENT_SUFFIX}" + + print(f"card_id: {card.get('card_id')}") + print(f"agent: {args.agent}") + print(f"fingerprint: {artifacts.fingerprint}") + print(f"private key: {artifacts.private_key_path}") + print(f"public key: {artifacts.public_key_path}") + print(f"ml patch:") + print(f" ssh_fingerprint: \"{artifacts.fingerprint}\"") + print(f" ssh_allowed_signers_line: \"{allowed_signers}\"") + + if args.dry_run: + print("\n(dry-run: cards file untouched; private key kept for inspection)") + return 0 + + if mode == "ruamel": + ml = card.setdefault("ml", {}) + ml["primary_method"] = "ssh" + ml["ssh_fingerprint"] = artifacts.fingerprint + ml["ssh_allowed_signers_line"] = allowed_signers + from ruamel.yaml import YAML + + yaml = YAML() + yaml.preserve_quotes = True + yaml.width = 4096 # never wrap the long allowed-signers line + with CARDS_PATH.open("w") as fh: + yaml.dump(data, fh) + print(f"\npatched {CARDS_PATH}") + return 0 + + print( + "\nnote: ruamel.yaml not installed — apply the ml patch above by hand " + "(pip install ruamel.yaml for auto-patch)", + file=sys.stderr, + ) + return 2 + + +def cmd_ssh_auth(args: argparse.Namespace) -> int: + """Generate a fleet SSH-access keypair for a node/agent.""" + out_dir = Path(args.out_dir).expanduser() if args.out_dir else DEFAULT_KEY_DIR + out_dir.mkdir(parents=True, exist_ok=True) + key_path = out_dir / args.name + if key_path.exists(): + print(f"error: key already exists at {key_path}", file=sys.stderr) + return 1 + + artifacts = generate_key(key_path, args) + print(json.dumps({ + "name": args.name, + "authorized_key": artifacts.authorized_key, + "fingerprint": artifacts.fingerprint, + "private_key_path": str(artifacts.private_key_path), + "public_key_path": str(artifacts.public_key_path), + "secrets_funnel_hint": f"point a *_FILE variable at {artifacts.private_key_path}", + }, indent=2)) + return 0 + + +def cmd_audit(args: argparse.Namespace) -> int: + """Report which cards still lack their ml SSH half.""" + import yaml + + data = yaml.safe_load(CARDS_PATH.read_text()) + pending, complete = [], [] + for card in data.get("cards", []): + agent = card.get("h", {}).get("agent_id", "?") + ml = card.get("ml", {}) or {} + has_ssh = bool(ml.get("ssh_fingerprint") and ml.get("ssh_allowed_signers_line")) + (complete if has_ssh else pending).append(agent) + print(f"complete ({len(complete)}): {', '.join(sorted(complete)) or '-'}") + print(f"pending-ml ({len(pending)}): {', '.join(sorted(pending)) or '-'}") + print("\nnext: python3 -m pmoves.tools.keygen_cards generate --agent ") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument("--passphrase-env", help="env var holding the key passphrase") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_gen = sub.add_parser("generate", help="generate + patch a signing card's ml half") + p_gen.add_argument("--agent", required=True, help="agent_id from signing_identity_cards.yaml") + p_gen.add_argument("--dry-run", action="store_true") + p_gen.set_defaults(func=cmd_generate) + + p_ssh = sub.add_parser("ssh-auth", help="generate a fleet SSH-access keypair") + p_ssh.add_argument("--name", required=True, help="node/agent name (e.g. jetson-1)") + p_ssh.add_argument("--out-dir") + p_ssh.set_defaults(func=cmd_ssh_auth) + + p_aud = sub.add_parser("audit", help="list cards missing their ml SSH half") + p_aud.set_defaults(func=cmd_audit) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main())