diff --git a/ACCESS.md b/ACCESS.md index 1329b558..12640d80 100644 --- a/ACCESS.md +++ b/ACCESS.md @@ -110,6 +110,8 @@ python -m messaging_service set-access --principal assistant-work \ --chats chat-work --permissions read python -m messaging_service list-keys --principal assistant-work +python -m messaging_service list-keys --all +python -m messaging_service key-info --key-file /dev/stdin < /private/assistant-work.token python -m messaging_service revoke-key --key-id PUBLIC_CREDENTIAL_UUID python -m messaging_service extend-key --key-id PUBLIC_CREDENTIAL_UUID --days 365 python -m messaging_service set-access --principal assistant-work --disable @@ -122,6 +124,16 @@ without `--chats`/`--permissions` keeps its restrictions; the account list must explicitly. Rotation with the same permissions does not reset the ACK. Issuing a key to a disabled principal does not enable it again. +### Key inventory + +`list-keys --all` lists every key of every principal (exactly one of `--principal` or `--all`): +public key ID, principal, `created_at` (the operator `issue_key` audit row, `null` when none +exists), `expires_at` and `revoked`. `key-info --key-file FILE` reads one token per line (at most +100 lines and 64 KiB; `/dev/stdin` for a batch) and prints, in input order, `found` with the +public key ID, principal, `expires_at` and `revoked`, or `found: false`; a line that cannot be a +token reports `error: invalid_key`. Neither command prints a token or a digest, and neither +writes. `deedkit admin key list` (docs/operations/production.md, Admin CLI) runs both. + ### Extending a key Since schema 36 `extend-key` sets one live key's expiry to now + `--days` (an integer 1–365, diff --git a/deploy/admin.example.json b/deploy/admin.example.json new file mode 100644 index 00000000..7e39489f --- /dev/null +++ b/deploy/admin.example.json @@ -0,0 +1,11 @@ +{ + "policy": "/opt/deedkit/deploy/private/gateway-policy/policy.json", + "calls_registry": "/opt/deedkit/deploy/private/calls-agents.json", + "labels": "/opt/deedkit/deploy/private/admin/labels.json", + "paths": { + "/run/deedkit/keys": "/opt/deedkit/deploy/private/gateway-keys", + "/run/deedkit/voice": "/opt/deedkit/deploy/private/voice" + }, + "messaging": ["docker", "compose", "--project-name", "", "--project-directory", "/opt/deedkit/deploy", "-f", "/opt/deedkit/deploy/compose.yaml", "-f", "/opt/deedkit/deploy/compose.operator.yaml", "--profile", "operator", "run", "--rm", "-T", "--no-deps", "--name", "{name}", "operator"], + "calendar": ["docker", "compose", "--project-name", "deedkit-calendar", "--env-file", "", "-f", "/opt/deedkit/deploy/compose.calendar.yaml", "run", "--rm", "-T", "--no-deps", "--name", "{name}", "--volume", "/service:/data/service:ro", "calendar-api"] +} diff --git a/docs/operations/production.md b/docs/operations/production.md index 491db865..31e2d68f 100644 --- a/docs/operations/production.md +++ b/docs/operations/production.md @@ -1027,6 +1027,39 @@ reason and the names of unfinished and serving attempts. The tool reads receipts After `LIVE`, `python3 scripts/feedback_sync.py follow` (dry run, then `--apply`) moves agent feedback whose card that release delivers (ANYM-473, docs/operations/feedback-triage.md). +## Admin CLI + +`python3 scripts/deedkit.py admin key list` (ANYM-494) shows every agent key in one view: one row +per gateway principal (label, enabled, gateway expiry) with the messaging, calendar and calls +credential bound to it (public ID, expiry, `live`, `revoked`, `expired`, `missing`, or `unknown` +when a backend command fails). It reads the gateway policy and the calls registry and runs the +messaging `list-keys --all` / `key-info` and calendar `list-agents` / `agent-info` operator +commands (ACCESS.md, `services/calendar/docs/SERVICE.md`); key files reach those commands on +stdin, never in argv, environment or output. It takes no lock and writes no store or file, so it +is safe during a call. The one-shot operator containers keep their stdout (public IDs only) in +Docker's container log until `run --rm` removes them; on a timeout or an interrupt the CLI stops its +own uniquely named container (`docker stop`, 30 s bound), and the calendar reads carry a 5 s +statement timeout. Filters: `--principal ID`, `--valid` (enabled, unexpired), `--expiring` (within +the gateway's 7-day warning window or expired), `--all` (default); `--json` for machine output; +`--strict` exits 1 when there are findings (exit 2 is an unreadable input). + +Findings, for every gateway principal, disabled and expired ones included (re-enabling one would +expose them): `credential_missing`, `credential_revoked`, `credential_expired`, +`credential_pin_mismatch` (the key file does not match the policy's `key_sha256`, so the gateway +refuses it), `backend_expires_first` (the backend key ends before the gateway entry), +`shared_credential` (one backend key bound to two or more principals), `unbound_key` (a backend +key no principal holds, with its status) and `backend_unavailable` (a failed backend command, or +no calls registry configured while a principal holds a calls key). With `--principal`, the list +keeps that principal's findings and every `backend_unavailable` of a backend it uses. + +The configuration is a private JSON file (`--config`, default +`/opt/deedkit/deploy/private/admin/config.json`; template `deploy/admin.example.json`): policy +and calls registry paths, container-to-host path mappings for key files, and the messaging and +calendar operator command prefixes. Every private file it reads must be a regular, owner-only +file (no group or other bits, no symlink). `admin key label ID TEXT` (1–80 printable characters, +an existing gateway principal) writes the label to `labels.json` beside it, mode 0600, atomically; +no service reads that file, so the enforced policy never changes. + ## Record the production release After a successful `apply`, the release owner records the release in Tiden from diff --git a/scripts/deedkit.py b/scripts/deedkit.py index 0797296c..91b41eb9 100644 --- a/scripts/deedkit.py +++ b/scripts/deedkit.py @@ -5,6 +5,7 @@ import json from pathlib import Path import subprocess +import sys import tomllib ROOT = Path(__file__).resolve().parents[1] @@ -138,6 +139,12 @@ def validate_files(plan, root=ROOT): def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + if argv[:1] == ["admin"]: + # `deedkit admin ...` (ANYM-491): host administration, separate from module deployment. + from deedkit_admin import main as admin + + sys.exit(admin(argv[1:])) parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("action", choices=["resolve", "up", "status", "stop"]) parser.add_argument("--profile", required=True, type=Path) diff --git a/scripts/deedkit_admin.py b/scripts/deedkit_admin.py new file mode 100644 index 00000000..5fd7dcdd --- /dev/null +++ b/scripts/deedkit_admin.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +"""DeedKit admin CLI (ANYM-491): every agent key across the gateway, messaging, calendar and calls stores. + +`key list` joins the gateway policy, the calls registry and each backend's own operator command (ANYM-133: +messaging `list-keys --all` / `key-info`, calendar `list-agents` / `agent-info`) into one row per gateway +principal, and reports where the stores disagree. It is read-only: no lock, no write, safe during a call. +`key label` keeps a human label in a private admin file that no service reads, so the enforced policy schema +never changes (an older gateway image rejects unknown fields). Host only, standard library only. + +Secrets: key files are read here and passed to the operator commands on stdin, never in argv, environment, +output or logs; the calls registry is matched by hashing in memory. Only public IDs are printed. +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta, timezone +import hashlib +import hmac +import json +import os +from pathlib import Path, PurePosixPath +import stat +import subprocess +import sys +import tempfile +from uuid import uuid4 + +CONFIG = Path("/opt/deedkit/deploy/private/admin/config.json") +WINDOW = timedelta(days=7) # the gateway's expiry warning window (ANYM-289, WARNING_WINDOW_DAYS) +TIMEOUT = 120 # seconds for one operator command +CLEANUP = 30 # seconds to stop this invocation's own container after a timeout or an interrupt +LIMIT = 1024 * 1024 # bytes of one private JSON or key file +BATCH = 100 # keys per lookup, the operator commands' own bound +LOOKUP = {"messaging": ["key-info", "--key-file", "/dev/stdin"], "calendar": ["agent-info", "--key-file", "/dev/stdin"]} +INVENTORY = {"messaging": ["list-keys", "--all"], "calendar": ["list-agents"]} + + +class AdminError(Exception): + """A refusal or unreadable input; the message names a file or a service, never a secret.""" + + +class Unavailable(Exception): + pass + + +def read_private(path: str | Path, limit: int = LIMIT) -> bytes: + """A regular, owner-only file that is not a symlink, read in full and unchanged while read (the gateway's + own reader); an oversized or growing file is refused, never truncated.""" + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + before = os.fstat(fd) + if not stat.S_ISREG(before.st_mode) or before.st_mode & 0o077 or before.st_size > limit: + raise AdminError(f"unsafe private file: {path}") + value, after = os.read(fd, limit + 1), os.fstat(fd) + if len(value) > limit or (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != ( + after.st_size, after.st_mtime_ns, after.st_ctime_ns): + raise AdminError(f"private file changed while read: {path}") + return value + finally: + os.close(fd) + + +def load_json(path: str | Path, what: str) -> dict: + try: + value = json.loads(read_private(path)) + except (OSError, ValueError) as exc: + raise AdminError(f"{what} unreadable: {path} ({type(exc).__name__})") from None + if not isinstance(value, dict): + raise AdminError(f"{what} is not a JSON object: {path}") + return value + + +def instant(value: str | None) -> datetime | None: + if not isinstance(value, str): + return None + try: + moment = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return moment if moment.tzinfo else None + + +def host_path(config: dict, path: str) -> str: + """A container path (policy key_file, registry archive_config, archive token_file) on the host, by the + longest configured mount prefix; a path under no prefix is taken as a host path.""" + pure, best = PurePosixPath(path), None + for prefix in config.get("paths", {}): + if pure == PurePosixPath(prefix) or PurePosixPath(prefix) in pure.parents: + if best is None or len(prefix) > len(best): + best = prefix + if best is None: + return path + return str(Path(config["paths"][best]) / pure.relative_to(best)) + + +def read_token(config: dict, path: str) -> str | None: + """The token in a key file, or None when it is unreadable or not one whitespace-free line.""" + try: + token = read_private(host_path(config, path), 1024).decode().strip() + except (OSError, ValueError, AdminError): + return None + return token if token and not any(c.isspace() for c in token) else None + + +def runner(config: dict): + """Run one operator command in its one-shot container; argv from the config, a secret only on stdin.""" + def run(service: str, args: list[str], data: bytes | None = None): + prefix = config.get(service) + if not isinstance(prefix, list) or not prefix or not all(isinstance(part, str) for part in prefix): + raise Unavailable(f"{service}: no operator command configured") + name = "deedkit-admin-" + uuid4().hex[:12] + argv = [part.replace("{name}", name) for part in prefix] + args + try: + done = subprocess.run(argv, input=data if data is not None else b"", capture_output=True, + timeout=TIMEOUT, check=False) + except OSError as exc: + raise Unavailable(f"{service}: {type(exc).__name__}") from None + except BaseException as exc: # timeout or interrupt: the killed client may leave its container running + if any("{name}" in part for part in prefix): + stop(name) + if isinstance(exc, subprocess.TimeoutExpired): + raise Unavailable(f"{service}: TimeoutExpired") from None + raise + # stderr is dropped: a service's error text is not ours to echo. + if done.returncode: + raise Unavailable(f"{service}: {args[0]} exited {done.returncode}") + try: + return json.loads(done.stdout) + except ValueError: + raise Unavailable(f"{service}: {args[0]} printed no JSON") from None + return run + + +def stop(name: str) -> None: + """Stop only this invocation's uniquely named container, bounded; `run --rm` makes the daemon remove it.""" + try: + subprocess.run(["docker", "stop", "--time", "5", name], capture_output=True, timeout=CLEANUP, check=False) + except (OSError, subprocess.TimeoutExpired): + pass + + +def pinned(token: str, pin: object) -> bool: + """The token matches the policy's key_sha256; a malformed pin (the gateway refuses it too) never matches.""" + if not isinstance(pin, str) or len(pin) != 64 or not all(c in "0123456789abcdef" for c in pin): + return False + return hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), pin) + + +def status(record: dict | None, now: datetime) -> str: + if record is None: + return "missing" + if record.get("revoked"): + return "revoked" + expiry = instant(record.get("expires_at")) + return "expired" if expiry is None or expiry <= now else "live" + + +def lookup(run, service: str, tokens: list[str]) -> list[dict | None]: + """Each token's public record from the service, in order; None where it is not found.""" + result: list[dict | None] = [] + for start in range(0, len(tokens), BATCH): + chunk = tokens[start:start + BATCH] + reply = run(service, LOOKUP[service], ("\n".join(chunk) + "\n").encode()) + rows = reply.get("keys" if service == "messaging" else "agents") if isinstance(reply, dict) else None + if not isinstance(rows, list) or len(rows) != len(chunk) or not all(isinstance(r, dict) for r in rows): + raise Unavailable(f"{service}: malformed lookup reply") + result += [row if row.get("found") else None for row in rows] + return result + + +def inventory(run, service: str) -> list[dict]: + reply = run(service, INVENTORY[service]) + rows = reply.get("agents") if service == "calendar" and isinstance(reply, dict) else reply + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + raise Unavailable(f"{service}: malformed inventory reply") + return rows + + +def public_id(service: str, record: dict) -> str | None: + if service == "messaging": + return record.get("key_id") or record.get("id") + return record.get("principal_id") or record.get("id") + + +def collect(config: dict, run, now: datetime | None = None) -> dict: + """The joined view: one row per gateway principal and the findings, public fields only.""" + now = now or datetime.now(timezone.utc) + policy = load_json(config["policy"], "gateway policy") + principals = policy.get("principals") + if not isinstance(principals, list) or not all(isinstance(p, dict) and isinstance(p.get("id"), str) + for p in principals): + raise AdminError(f"gateway policy has no principal list: {config['policy']}") + registry = load_json(config["calls_registry"], "calls registry") if config.get("calls_registry") else {} + calls = [entry for entry in registry.get("principals", []) if isinstance(entry, dict)] + by_hash = {entry.get("key_sha256"): entry for entry in calls} + labels = load_labels(config) + pending: dict[str, list[tuple[dict, str]]] = {"messaging": [], "calendar": []} + rows, unconfigured = [], False + for principal in principals: + credentials = [] + items = principal.get("credentials") if isinstance(principal.get("credentials"), dict) else {} + for backend, credential in sorted(items.items()): + entry = {"backend": backend, "public_id": None, "expires_at": None, "status": "missing"} + token = read_token(config, credential.get("key_file", "")) if isinstance(credential, dict) else None + if token is None: + entry["note"] = "key_file_unreadable" + elif not pinned(token, credential.get("key_sha256")): + # The gateway refuses a key file that does not match its pin, so the binding does not work. + entry.update(status="unknown", note="credential_pin_mismatch") + elif backend in pending: + pending[backend].append((entry, token)) + elif backend == "calls" and not config.get("calls_registry"): + entry.update(status="unknown", note="calls_registry_not_configured") + unconfigured = True + elif backend == "calls": + match = by_hash.get(hashlib.sha256(token.encode()).hexdigest()) + if match is not None: + entry.update(public_id=match.get("id"), expires_at=match.get("expires_at"), + status=status({"revoked": not match.get("enabled"), + "expires_at": match.get("expires_at")}, now)) + if match.get("archive_config"): + entry["archive"] = archive(config, match["archive_config"], pending) + else: + entry.update(status="unknown", note="unsupported_backend") + credentials.append(entry) + rows.append({"id": principal["id"], "label": labels.get(principal["id"]), + "profile": principal.get("profile"), "enabled": principal.get("enabled") is True, + "expires_at": principal.get("expires_at"), "credentials": credentials}) + unavailable, records = [], {} + if unconfigured: + unavailable.append({"code": "backend_unavailable", "backend": "calls", "detail": "calls: no registry configured"}) + for service, items in pending.items(): + # The lookup and the inventory fail apart: a failed inventory keeps the rows' statuses and only loses + # this backend's unbound_key findings. + try: + found = lookup(run, service, [token for _, token in items]) if items else [] + except Unavailable as exc: + unavailable.append({"code": "backend_unavailable", "backend": service, "detail": str(exc)}) + for entry, _ in items: + entry["status"] = "unknown" + continue + try: + records[service] = inventory(run, service) + except Unavailable as exc: + unavailable.append({"code": "backend_unavailable", "backend": service, "detail": str(exc)}) + for (entry, _), record in zip(items, found, strict=True): + entry["status"] = status(record, now) + if record is not None: + entry.update(public_id=public_id(service, record), backend_principal=record.get("principal"), + expires_at=record.get("expires_at")) + items.clear() + if config.get("calls_registry"): + records["calls"] = [{"id": entry.get("id"), "expires_at": entry.get("expires_at"), + "revoked": not entry.get("enabled")} for entry in calls] + return {"principals": rows, "findings": unavailable + findings(rows, records, now)} + + +def archive(config: dict, path: str, pending: dict) -> dict: + """The calls entry's own messaging credential (archive_config -> token_file), looked up with the rest.""" + entry = {"backend": "messaging", "public_id": None, "expires_at": None, "status": "missing"} + try: + settings = json.loads(read_private(host_path(config, path), 65536)) + token = read_token(config, settings["token_file"]) + except (OSError, ValueError, KeyError, TypeError, AdminError): + token = None + if token is None: + entry["note"] = "archive_unreadable" + else: + pending["messaging"].append((entry, token)) + return entry + + +def bound(row: dict): + for credential in row["credentials"]: + yield credential + if "archive" in credential: + yield credential["archive"] + + +def findings(rows: list[dict], records: dict, now: datetime) -> list[dict]: + result, users = [], {} + # Every principal counts, disabled or expired too: re-enabling or extending it would expose the finding. + for row in rows: + gateway = instant(row["expires_at"]) + for credential in bound(row): + if credential["public_id"] is not None: + users.setdefault((credential["backend"], credential["public_id"]), []).append(row["id"]) + if credential["status"] in ("missing", "revoked", "expired"): + result.append({"code": "credential_" + credential["status"], "principal": row["id"], + "backend": credential["backend"], "public_id": credential["public_id"]}) + if credential.get("note") == "credential_pin_mismatch": + result.append({"code": "credential_pin_mismatch", "principal": row["id"], + "backend": credential["backend"]}) + backend = instant(credential["expires_at"]) + if credential["status"] == "live" and backend is not None and gateway is not None and backend < gateway: + result.append({"code": "backend_expires_first", "principal": row["id"], + "backend": credential["backend"], "public_id": credential["public_id"], + "expires_at": credential["expires_at"], "gateway_expires_at": row["expires_at"]}) + for (backend, key), names in sorted(users.items()): + if len(set(names)) > 1: + result.append({"code": "shared_credential", "backend": backend, "public_id": key, + "principals": sorted(set(names))}) + for backend, inventory_rows in sorted(records.items()): + for record in inventory_rows: + key = public_id(backend, record) + if (backend, key) not in users: + result.append({"code": "unbound_key", "backend": backend, "public_id": key, + "status": status(record, now), + "backend_principal": record.get("principal") or record.get("person_id")}) + return result + + +def select(view: dict, principal: str | None, scope: str, now: datetime) -> dict: + """Rows by --principal and --valid/--expiring/--all. Findings: all without --principal; with it, those that + name a shown row and every unavailable backend a shown row uses.""" + def expiries(row): + return [instant(row["expires_at"])] + [instant(c["expires_at"]) for c in bound(row)] + + def keep(row): + if principal is not None and row["id"] != principal: + return False + if scope == "valid": + expiry = instant(row["expires_at"]) + return row["enabled"] and expiry is not None and expiry > now + if scope == "expiring": # in the window or already expired, as the gateway's own --check lists them + gateway, *backends = expiries(row) + return gateway is None or any(m is not None and m - now <= WINDOW for m in [gateway, *backends]) + return True + + rows = [row for row in view["principals"] if keep(row)] + shown = {row["id"] for row in rows} + used = {credential["backend"] for row in rows for credential in bound(row)} + found = [item for item in view["findings"] + if principal is None or item.get("principal") in shown or shown & set(item.get("principals", [])) + or (item["code"] == "backend_unavailable" and item["backend"] in used)] + return {"principals": rows, "findings": found} + + +def load_labels(config: dict) -> dict: + path = Path(config.get("labels", CONFIG.with_name("labels.json"))) + if not path.exists() and not path.is_symlink(): + return {} + labels = load_json(path, "labels").get("labels", {}) + return {k: v for k, v in labels.items() if isinstance(k, str) and isinstance(v, str)} + + +def set_label(config: dict, principal: str, text: str) -> dict: + if not 1 <= len(text) <= 80 or not text.isprintable(): + raise AdminError("a label is 1-80 printable characters") + policy = load_json(config["policy"], "gateway policy") + if principal not in {p.get("id") for p in policy.get("principals", []) if isinstance(p, dict)}: + raise AdminError(f"no gateway principal {principal!r}") + path = Path(config.get("labels", CONFIG.with_name("labels.json"))) + labels = load_labels(config) + labels[principal] = text + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".labels-", suffix=".json") + try: + with os.fdopen(fd, "w") as out: # mkstemp creates it 0600 + json.dump({"version": 1, "labels": dict(sorted(labels.items()))}, out, ensure_ascii=False, indent=2) + out.write("\n") + out.flush() + os.fsync(out.fileno()) + os.replace(temporary, path) + except BaseException: + Path(temporary).unlink(missing_ok=True) + raise + return {"principal": principal, "label": text} + + +def render(view: dict) -> str: + lines = [] + for row in view["principals"]: + state = "enabled" if row["enabled"] else "disabled" + label = json.dumps(row["label"], ensure_ascii=False) if row["label"] else "-" + lines.append(f"{row['id']} {label} profile={row['profile']} {state} expires {row['expires_at']}") + for credential in row["credentials"]: + lines.append(line(" ", credential)) + if "archive" in credential: + lines.append(line(" archive ", credential["archive"])) + lines.append(f"{len(view['principals'])} principals, {len(view['findings'])} findings") + for item in view["findings"]: + rest = " ".join(f"{k}={v}" for k, v in item.items() if k != "code") + lines.append(f" {item['code']} {rest}") + return "\n".join(lines) + + +def line(indent: str, credential: dict) -> str: + note = f" ({credential['note']})" if credential.get("note") else "" + return (f"{indent}{credential['backend']:<9} {credential['public_id'] or '-'} {credential['status']}" + f" expires {credential['expires_at'] or '-'}{note}") + + +def main(argv: list[str] | None = None, run=None) -> int: + parser = argparse.ArgumentParser(prog="deedkit admin", description="DeedKit admin CLI (ANYM-491)") + parser.add_argument("--config", type=Path, default=CONFIG, help=f"private admin configuration (default {CONFIG})") + nouns = parser.add_subparsers(dest="noun", required=True) + verbs = nouns.add_parser("key", help="agent keys").add_subparsers(dest="verb", required=True) + listing = verbs.add_parser("list", help="every agent key, read-only") + listing.add_argument("--principal") + scope = listing.add_mutually_exclusive_group() + scope.add_argument("--valid", action="store_const", const="valid", dest="scope") + scope.add_argument("--expiring", action="store_const", const="expiring", dest="scope") + scope.add_argument("--all", action="store_const", const="all", dest="scope") + listing.add_argument("--json", action="store_true") + listing.add_argument("--strict", action="store_true", help="exit 1 when there are findings") + label = verbs.add_parser("label", help="set a principal's label in the private admin file") + label.add_argument("principal") + label.add_argument("text") + args = parser.parse_args(argv) + try: + config = load_json(args.config, "admin configuration") + if args.verb == "label": + print(json.dumps(set_label(config, args.principal, args.text), ensure_ascii=False)) + return 0 + now = datetime.now(timezone.utc) + view = select(collect(config, run or runner(config), now), args.principal, args.scope or "all", now) + except (AdminError, KeyError) as exc: + print(f"deedkit admin: {exc}", file=sys.stderr) + return 2 + print(json.dumps(view, ensure_ascii=False, indent=2) if args.json else render(view)) + return 1 if args.strict and view["findings"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/services/calendar/docs/SERVICE.md b/services/calendar/docs/SERVICE.md index b313ecd2..dc096764 100644 --- a/services/calendar/docs/SERVICE.md +++ b/services/calendar/docs/SERVICE.md @@ -54,7 +54,7 @@ uv run family-calendar service grant --member egor --calendar CALENDAR_REF --sco uv run family-calendar service agent-key --id planner --member egor --calendar CALENDAR_REF --scopes agenda:read,availability:read,event:read,assignment:read,assignment:write,plan:create,plan:execute,event:create,event:update,event:delete --output .local/service/keys/planner.key ``` -For several calendars, repeat `--calendar`. New calendars are not added automatically. Each agent gets a separate key, valid for 90 days; the DB stores only the SHA-256. `revoke-agent --id planner` revokes the key and unfinished plans. `extend-agent --id planner --days N` sets the expiry of an enabled, unexpired key to now + N days (database clock, N from 1 to 365) and prints the principal ID with the previous and new expiry; it refuses a shorter expiry (`agent_lifetime_would_shorten`), an expired key (`agent_expired`) and an unknown or revoked one (`not_found_or_forbidden`), and writes one `extend_agent` audit row. For a key valid for a year, run `extend-agent --days 365` right after `agent-key`. The key, scopes, calendars and principal version stay the same, so prepared plans still apply and the gateway policy needs no change. For a new key use a new principal ID. `disconnect --connection ID` disconnects the account and the write opt-in; connect it again with a new connection ID and explicit grants. +For several calendars, repeat `--calendar`. New calendars are not added automatically. Each agent gets a separate key, valid for 90 days; the DB stores only the SHA-256. `revoke-agent --id planner` revokes the key and unfinished plans. `extend-agent --id planner --days N` sets the expiry of an enabled, unexpired key to now + N days (database clock, N from 1 to 365) and prints the principal ID with the previous and new expiry; it refuses a shorter expiry (`agent_lifetime_would_shorten`), an expired key (`agent_expired`) and an unknown or revoked one (`not_found_or_forbidden`), and writes one `extend_agent` audit row. For a key valid for a year, run `extend-agent --days 365` right after `agent-key`. The key, scopes, calendars and principal version stay the same, so prepared plans still apply and the gateway policy needs no change. For a new key use a new principal ID. `list-agents` lists every agent principal with public fields only (principal ID, group, person, `expires_at`, `revoked` for a disabled key, number of granted calendars); `agent-info --key-file FILE` reads one key per line (at most 100 lines and 64 KiB; `/dev/stdin` for a batch) and prints, in input order, the principal ID, `expires_at` and `revoked`, or `found: false` (`error: invalid_key` for a line that cannot be a key). Neither prints a key or its hash, and neither writes. `disconnect --connection ID` disconnects the account and the write opt-in; connect it again with a new connection ID and explicit grants. Writing is off by default. The owner's choice: diff --git a/services/calendar/family_calendar/service/cli.py b/services/calendar/family_calendar/service/cli.py index 24446f0d..60e7613c 100644 --- a/services/calendar/family_calendar/service/cli.py +++ b/services/calendar/family_calendar/service/cli.py @@ -18,6 +18,9 @@ from .vault import Vault +KEY_INFO_BYTES = 64 * 1024 + + def parser(): p = argparse.ArgumentParser(description="Family Calendar owner CLI / isolated gateway / worker") sub = p.add_subparsers(dest="action", required=True) @@ -83,6 +86,9 @@ def parser(): extend = sub.add_parser("extend-agent") extend.add_argument("--id", required=True) extend.add_argument("--days", required=True, help="New lifetime from now, 1-365 days") + sub.add_parser("list-agents") + info = sub.add_parser("agent-info") + info.add_argument("--key-file", type=Path, required=True, help="One key per line; /dev/stdin for a batch") disconnect = sub.add_parser("disconnect") disconnect.add_argument("--connection", required=True) reconcile = sub.add_parser("reconcile") @@ -258,6 +264,15 @@ def dispatch(args, home, root, config, store): if args.action == "extend-agent": previous, new = store.extend_agent(args.id, args.days) return {"principal_id": args.id, "previous_expires_at": previous.isoformat(), "expires_at": new.isoformat()} + if args.action == "list-agents": + return {"agents": store.list_agents()} + if args.action == "agent-info": + # ANYM-494: public fields by key file, in input order; the key and its hash are never printed. + with args.key_file.open("rb") as source: + raw = source.read(KEY_INFO_BYTES + 1) + if len(raw) > KEY_INFO_BYTES: + raise CalendarError("key_file_too_large") + return {"agents": store.agent_info(raw.decode().splitlines())} if args.action == "guardian-rule": if args.rule_action == "set": return store.guardian_rule_set( diff --git a/services/calendar/family_calendar/service/store.py b/services/calendar/family_calendar/service/store.py index 9c142ea3..0d920878 100644 --- a/services/calendar/family_calendar/service/store.py +++ b/services/calendar/family_calendar/service/store.py @@ -239,6 +239,36 @@ def extend_agent(self, principal_id, days): ) return p["expires_at"], p["new"] + def list_agents(self): + # ANYM-494: every agent principal with public fields only; revoked means disabled. + with self.transaction() as conn: + conn.execute("SET LOCAL statement_timeout = '5s'") # an admin read never outlives its CLI + rows = conn.execute( + "SELECT p.id AS principal_id,p.group_id,p.person_id,p.expires_at,NOT p.enabled AS revoked," + "(SELECT count(*) FROM agent_calendars a WHERE a.principal_id=p.id) AS calendars " + "FROM principals p ORDER BY p.id" + ).fetchall() + return [dict(row, expires_at=row["expires_at"].isoformat()) for row in rows] + + def agent_info(self, keys): + # ANYM-494: an agent's public record by its key, in input order; the hash never leaves this method. + if not isinstance(keys, list) or not 1 <= len(keys) <= 100: + raise CalendarError("invalid_key_count") + result = [] + with self.transaction() as conn: + conn.execute("SET LOCAL statement_timeout = '5s'") + for key in keys: + key = key.strip() + if not 1 <= len(key) <= 512 or any(c.isspace() for c in key): + result.append({"found": False, "error": "invalid_key"}) + continue + row = conn.execute( + "SELECT id AS principal_id,expires_at,NOT enabled AS revoked FROM principals WHERE key_hash=%s", + (hashlib.sha256(key.encode()).hexdigest(),), + ).fetchone() + result.append(dict(row, found=True, expires_at=row["expires_at"].isoformat()) if row else {"found": False}) + return result + def guardian_rule_set(self, group_id, dependent, activity, duty, guardian, source, target, lead, title, visibility, copy_location=False, expected_version=None): # Owner CLI only (SERVICE.md): a rule is the default drive duty, never a write authorization. diff --git a/services/calendar/tests/service/test_agent_inventory.py b/services/calendar/tests/service/test_agent_inventory.py new file mode 100644 index 00000000..75deca8b --- /dev/null +++ b/services/calendar/tests/service/test_agent_inventory.py @@ -0,0 +1,45 @@ +"""Calendar agent inventory and key-file lookup for the admin CLI (ANYM-494): public fields only.""" +import hashlib +import json + +from family_calendar.service import cli + + +def dispatch(store, tmp_path, *argv): + return cli.dispatch(cli.parser().parse_args(list(argv)), tmp_path, tmp_path, {}, store) + + +def agents(store): + store.grant("family", "adult-a", "cal-a", ["agenda:read"], "details") + first = store.agent("planner", "family", "adult-a", ["agenda:read"], ["cal-a"]) + second = store.agent("helper", "family", "adult-a", ["agenda:read"], []) + store.revoke("helper") + return first, second + + +# spec: ANYM-494 +def test_list_agents_lists_every_agent_with_public_fields_only(household, tmp_path): + first, second = agents(household) + result = dispatch(household, tmp_path, "list-agents") + rows = result["agents"] + assert [(r["principal_id"], r["revoked"], r["calendars"]) for r in rows] == [("helper", True, 0), ("planner", False, 1)] + assert all(set(r) == {"principal_id", "group_id", "person_id", "expires_at", "revoked", "calendars"} for r in rows) + text = json.dumps(result) + for secret in (first, second, hashlib.sha256(first.encode()).hexdigest()): + assert secret not in text + + +# spec: ANYM-494 +def test_agent_info_answers_each_line_of_a_key_file_in_order_without_the_key_or_hash(household, tmp_path): + first, second = agents(household) + batch = tmp_path / "batch" + batch.write_text(f"{second}\nunknown-key\nnot a key\n{first}\n") + result = dispatch(household, tmp_path, "agent-info", "--key-file", str(batch)) + rows = result["agents"] + assert [r["found"] for r in rows] == [True, False, False, True] + assert (rows[0]["principal_id"], rows[0]["revoked"]) == ("helper", True) + assert set(rows[3]) == {"found", "principal_id", "expires_at", "revoked"} and rows[3]["principal_id"] == "planner" + assert rows[1] == {"found": False} and rows[2] == {"found": False, "error": "invalid_key"} + text = json.dumps(result) + for secret in (first, second, hashlib.sha256(first.encode()).hexdigest()): + assert secret not in text diff --git a/services/messaging/messaging_service/__main__.py b/services/messaging/messaging_service/__main__.py index 3b4125e1..e34a0bff 100644 --- a/services/messaging/messaging_service/__main__.py +++ b/services/messaging/messaging_service/__main__.py @@ -8,6 +8,8 @@ from .policy import PERMISSIONS, UNSET from .messenger_operations import OPERATIONS +KEY_INFO_BYTES=64*1024 + def main(): from .telemetry import configure @@ -16,11 +18,12 @@ def main(): 'replay','snapshot','verify-restore','quarantine-restore','register-account', 'set-access','list-keys','audit-tail','set-sending','set-key-permissions', 'set-messenger-operations','extend-key','set-feedback-status','export-feedback','release-retention-hold', - 'set-subscription-retention','link-person','unlink-person','delete-person','list-people']) + 'set-subscription-retention','link-person','unlink-person','delete-person','list-people','key-info']) parser.add_argument('--principal') parser.add_argument('--accounts',nargs='+') parser.add_argument('--key-file',type=Path) parser.add_argument('--key-id',help='Public credential UUID; never the token or its hash') + parser.add_argument('--all',action='store_true',dest='all_keys',help='list-keys: every key of every principal') group=parser.add_mutually_exclusive_group() group.add_argument('--chats',nargs='*',default=UNSET) group.add_argument('--all-chats',action='store_true') @@ -180,8 +183,14 @@ def main(): parser.error('set-key-permissions requires --key-id and explicit --permissions') print(json.dumps(store.set_key_permissions(args.key_id,args.permissions))) elif args.command=='list-keys': - if not args.principal:parser.error('list-keys requires --principal') - print(json.dumps(store.list_keys(args.principal))) + if bool(args.principal)==args.all_keys:parser.error('list-keys requires exactly one of --principal or --all') + print(json.dumps(store.list_all_keys() if args.all_keys else store.list_keys(args.principal))) + elif args.command=='key-info': + # ANYM-494: one token per line (a key file, or a batch on stdin); public fields only, never a digest. + if not args.key_file or args.key_id:parser.error('key-info requires --key-file') + with args.key_file.open('rb') as source:raw=source.read(KEY_INFO_BYTES+1) + if len(raw)>KEY_INFO_BYTES:raise ValueError('key_file_too_large') + print(json.dumps({'keys':store.key_info(raw.decode().splitlines())})) elif args.command=='audit-tail': print(json.dumps(store.audit_tail(args.principal or '',args.limit))) else: diff --git a/services/messaging/messaging_service/store.py b/services/messaging/messaging_service/store.py index 235e6c86..806dffc2 100644 --- a/services/messaging/messaging_service/store.py +++ b/services/messaging/messaging_service/store.py @@ -1196,6 +1196,28 @@ def list_keys(self,principal): return serial(c.execute('SELECT id,principal,expires_at,revoked,permissions,permission_version FROM api_keys WHERE principal=%s ORDER BY expires_at,id', (principal,)).fetchall()) + def list_all_keys(self): + # ANYM-494: every key with public fields only; created_at is the operator's issue_key audit row, if any. + with self.pool.connection() as c: + return serial(c.execute('''SELECT k.id,k.principal,i.created_at,k.expires_at,k.revoked FROM api_keys k + LEFT JOIN (SELECT key_id,min(occurred_at) AS created_at FROM audit_log + WHERE operation='issue_key' AND actor='operator' GROUP BY key_id) i ON i.key_id=k.id + ORDER BY k.principal,k.expires_at,k.id''').fetchall()) + + def key_info(self,tokens): + # ANYM-494: a token's public record by its digest, in input order; the digest never leaves this method. + if not isinstance(tokens,list) or not 1<=len(tokens)<=100:raise ValueError('invalid_key_count') + result=[] + with self.pool.connection() as c: + for token in tokens: + token=token.strip() + if not 32<=len(token)<=256 or any(ch.isspace() for ch in token): + result.append({'found':False,'error':'invalid_key'});continue + row=c.execute('SELECT id AS key_id,principal,expires_at,revoked FROM api_keys WHERE digest=%s', + (digest(token),)).fetchone() + result.append(serial({'found':True,**row}) if row else {'found':False}) + return result + def audit_tail(self,principal='',limit=50): bounded(limit) if principal:principal_id(principal) diff --git a/tests/test_admin_key_inventory.py b/tests/test_admin_key_inventory.py new file mode 100644 index 00000000..d0d94447 --- /dev/null +++ b/tests/test_admin_key_inventory.py @@ -0,0 +1,69 @@ +"""Messaging inventory and key-file lookup for the admin CLI (ANYM-494): public fields only, never a digest.""" +import json +import os +import sys + +import pytest + +from messaging_service import __main__ as operator +from messaging_service.store import digest + + +@pytest.fixture +def run(seeded, monkeypatch, capsys): + monkeypatch.setenv('DATABASE_URL_FILE', os.environ['PROBE_TEST_DSN_FILE']) + + def invoke(*arguments): + monkeypatch.setattr(sys, 'argv', ['anymsg', *arguments]) + operator.main() + out = capsys.readouterr() + return json.loads(out.out), out.out+out.err + return invoke + + +def key_ids(store): + with store.pool.connection() as c: + return {row['principal']: str(row['id']) for row in c.execute('SELECT principal,id FROM api_keys')} + + +# spec: ANYM-494 +def test_list_keys_all_lists_every_principals_keys_with_public_fields_only(seeded, run): + store, one, two = seeded + ids = key_ids(store) + store.revoke(two) + rows, text = run('list-keys', '--all') + assert [(row['principal'], row['id']) for row in rows] == [('agent-a', ids['agent-a']), ('agent-b', ids['agent-b'])] + assert all(set(row) == {'id', 'principal', 'created_at', 'expires_at', 'revoked'} for row in rows) + assert [row['revoked'] for row in rows] == [False, True] + # created_at is the operator's issue_key audit row. + with store.pool.connection() as c: + issued = c.execute("SELECT min(occurred_at) AS at FROM audit_log WHERE operation='issue_key' AND key_id=%s", + (ids['agent-a'],)).fetchone()['at'] + assert rows[0]['created_at'] == issued.isoformat() + for secret in (one, two, digest(one), digest(two)): + assert secret not in text + + +# spec: ANYM-494 +def test_key_info_answers_each_line_of_a_key_file_in_order_without_the_token_or_digest(seeded, run, tmp_path): + store, one, two = seeded + ids = key_ids(store) + batch = tmp_path/'batch' + batch.write_text(f'{two}\npos_probe_{"x"*40}\nnot a key\n{one}\n') + reply, text = run('key-info', '--key-file', str(batch)) + keys = reply['keys'] + assert [k['found'] for k in keys] == [True, False, False, True] + assert (keys[0]['key_id'], keys[0]['principal'], keys[0]['revoked']) == (ids['agent-b'], 'agent-b', False) + assert set(keys[0]) == {'found', 'key_id', 'principal', 'expires_at', 'revoked'} + assert keys[1] == {'found': False} and keys[2] == {'found': False, 'error': 'invalid_key'} + assert keys[3]['key_id'] == ids['agent-a'] + for secret in (one, two, digest(one), digest(two)): + assert secret not in text + + +# spec: ANYM-494 +@pytest.mark.parametrize('arguments', [['list-keys'], ['list-keys', '--all', '--principal', 'agent-a'], ['key-info']]) +def test_inventory_commands_refuse_ambiguous_selectors(seeded, run, arguments): + with pytest.raises(SystemExit) as done: + run(*arguments) + assert done.value.code == 2 diff --git a/tests/test_deedkit_admin_list.py b/tests/test_deedkit_admin_list.py new file mode 100644 index 00000000..9dadc17e --- /dev/null +++ b/tests/test_deedkit_admin_list.py @@ -0,0 +1,473 @@ +"""`deedkit admin key list` and `key label` (ANYM-494) over synthetic stores and fake operator commands.""" +import hashlib +import json +from pathlib import Path +import sys + +import pytest + +from scripts import deedkit_admin as admin + +NOW = "2026-10-24T12:00:00+00:00" +LATER = "2027-04-01T00:00:00+00:00" +SOON = "2026-10-27T00:00:00+00:00" +PAST = "2026-10-01T00:00:00+00:00" +FAKE = r''' +import hashlib, json, sys +state = json.load(open(sys.argv[1])) +with open(sys.argv[1] + ".calls", "a") as log: + log.write(json.dumps({"argv": sys.argv[2:], "env": dict(__import__("os").environ)}) + "\n") +service, command = sys.argv[2], sys.argv[3] +if command in state.get("fail", []): + sys.exit(3) +if command in ("key-info", "agent-info"): + rows = [] + for line in sys.stdin.read().splitlines(): + record = state[service]["by_digest"].get(hashlib.sha256(line.strip().encode()).hexdigest()) + rows.append(dict(record, found=True) if record else {"found": False}) + print(json.dumps({"keys" if service == "messaging" else "agents": rows})) +elif command == "list-keys": + print(json.dumps(state["messaging"]["inventory"])) +elif command == "list-agents": + print(json.dumps({"agents": state["calendar"]["inventory"]})) +else: + sys.exit(2) +''' + + +def token(name): + return f"synthetic-{name}-" + hashlib.sha256(name.encode()).hexdigest()[:40] + + +def sha(value): + return hashlib.sha256(value.encode()).hexdigest() + + +def private(path, value): + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + path.write_text(value if isinstance(value, str) else json.dumps(value)) + path.chmod(0o600) + return path + + +class Stores: + """A host layout like production's: policy, gateway key files, calls registry, archive files, admin labels.""" + + def __init__(self, root): + self.root, self.tokens = root, {} + self.messaging, self.calendar, self.registry, self.principals = {}, {}, [], [] + self.config = {"policy": str(root / "gateway-policy/policy.json"), + "calls_registry": str(root / "calls-agents.json"), + "labels": str(root / "admin/labels.json"), + "paths": {"/run/deedkit/keys": str(root / "gateway-keys"), + "/run/deedkit/voice": str(root / "voice")}} + + def key(self, name): + self.tokens[name] = token(name) + private(self.root / "gateway-keys" / (name + ".key"), self.tokens[name] + "\n") + return {"key_file": f"/run/deedkit/keys/{name}.key", "key_sha256": sha(self.tokens[name])} + + def messaging_key(self, name, key_id, principal, expires=LATER, revoked=False, bound=True): + self.tokens[name] = token(name) + self.messaging[key_id] = {"digest": sha(self.tokens[name]), "key_id": key_id, "principal": principal, + "expires_at": expires, "revoked": revoked} + return self.key(name) if bound else None + + def calendar_agent(self, name, expires=LATER, revoked=False, bound=True): + self.tokens[name] = token(name) + self.calendar[name] = {"digest": sha(self.tokens[name]), "principal_id": name, "expires_at": expires, + "revoked": revoked} + return self.key(name) if bound else None + + def calls_agent(self, name, expires=LATER, enabled=True, archive=None): + credential = self.key(name) + entry = {"id": name, "key_sha256": credential["key_sha256"], "enabled": enabled, "expires_at": expires, + "scopes": ["calls:read"]} + if archive: + private(self.root / "voice" / (archive + ".token"), self.tokens[archive]) + private(self.root / "voice" / (archive + ".json"), + {"base_url": "http://messaging.invalid", "token_file": f"/run/deedkit/voice/{archive}.token"}) + entry["archive_config"] = f"/run/deedkit/voice/{archive}.json" + self.registry.append(entry) + return credential + + def principal(self, name, credentials, expires=LATER, enabled=True, profile="full"): + self.principals.append({"id": name, "key_sha256": sha("gateway-" + name), "profile": profile, + "enabled": enabled, "expires_at": expires, "grants": {}, + "credentials": credentials}) + + def write(self): + private(Path(self.config["policy"]), {"version": 1, "revision": 3, "deployment": {"modules": [], "backends": {}}, + "profiles": {"full": {"tools": []}}, "principals": self.principals}) + private(Path(self.config["calls_registry"]), {"version": 1, "revision": 2, "principals": self.registry}) + public = lambda rows: [{k: v for k, v in row.items() if k != "digest"} for row in rows] # noqa: E731 + return {"messaging": {"by_digest": {r["digest"]: public([r])[0] for r in self.messaging.values()}, + "inventory": [dict(public([r])[0], id=r["key_id"]) for r in self.messaging.values()]}, + "calendar": {"by_digest": {r["digest"]: public([r])[0] for r in self.calendar.values()}, + "inventory": public(self.calendar.values())}} + + def fake(self, broken=(), fail=()): + """Real subprocess runner over a fake operator script: proves what reaches argv, stdin and environment. + `broken` services exit 3 on every command, `fail` commands exit 3 alone.""" + config = self.root / "admin/config.json" + if config.exists() and not broken and not fail: # written once, so a read-only check sees no rewrite + return config + state = self.root / "operator-state.json" + state.write_text(json.dumps(dict(self.write(), fail=list(fail)))) + script = self.root / "fake_operator.py" + script.write_text(FAKE) + for service in ("messaging", "calendar"): + self.config[service] = [sys.executable, str(script), str(state), service] + if service in broken: + self.config[service] = [sys.executable, "-c", "import sys; sys.exit(3)"] + private(self.root / "admin/config.json", self.config) + return self.root / "admin/config.json" + + def snapshot(self): + return {str(p): (p.read_bytes(), p.stat().st_mtime_ns) for p in sorted(self.root.rglob("*")) + if p.is_file() and not p.name.endswith(".calls")} + + +@pytest.fixture +def stores(tmp_path, monkeypatch): + monkeypatch.setattr(admin, "datetime", Frozen) + return Stores(tmp_path) + + +class Frozen(admin.datetime): + @classmethod + def now(cls, tz=None): # noqa: ARG003 + return admin.datetime.fromisoformat(NOW) + + +def healthy(stores): + stores.messaging_key("cc-messaging", "00000000-0000-4000-8000-000000000001", "claude-code") + stores.messaging_key("cc-archive", "00000000-0000-4000-8000-000000000002", "claude-code-calls-archive", + bound=False) + stores.principal("claude-code", {"messaging": stores.key("cc-messaging"), + "calendar": stores.calendar_agent("cc-calendar"), + "calls": stores.calls_agent("cc-calls", archive="cc-archive")}) + + +def listing(stores, capsys, *arguments, broken=(), fail=()): + code = admin.main(["--config", str(stores.fake(broken, fail)), "key", "list", "--json", *arguments]) + out = capsys.readouterr() + return code, json.loads(out.out), out + + +# spec: ANYM-494 +def test_one_row_per_gateway_principal_with_each_backend_credential_and_the_nested_archive(stores, capsys): + healthy(stores) + stores.principal("reader", {"messaging": stores.messaging_key("r-messaging", "00000000-0000-4000-8000-000000000003", + "reader", expires=SOON)}, + expires=SOON, profile="read") + code, view, _ = listing(stores, capsys) + assert code == 0 and [row["id"] for row in view["principals"]] == ["claude-code", "reader"] + row = view["principals"][0] + assert {k: row[k] for k in ("label", "profile", "enabled", "expires_at")} == { + "label": None, "profile": "full", "enabled": True, "expires_at": LATER} + credentials = {c["backend"]: c for c in row["credentials"]} + assert credentials["messaging"] == {"backend": "messaging", "public_id": "00000000-0000-4000-8000-000000000001", + "backend_principal": "claude-code", "expires_at": LATER, "status": "live"} + assert (credentials["calendar"]["public_id"], credentials["calendar"]["status"]) == ("cc-calendar", "live") + assert (credentials["calls"]["public_id"], credentials["calls"]["status"]) == ("cc-calls", "live") + assert credentials["calls"]["archive"] == { + "backend": "messaging", "public_id": "00000000-0000-4000-8000-000000000002", + "backend_principal": "claude-code-calls-archive", "expires_at": LATER, "status": "live"} + assert view["findings"] == [] + + +# spec: ANYM-494 +def test_valid_expiring_and_principal_filters_use_the_gateway_window(stores, capsys): + stores.principal("live", {"calendar": stores.calendar_agent("live-cal")}) + stores.principal("soon", {"calendar": stores.calendar_agent("soon-cal")}, expires=SOON) + stores.principal("backend-soon", {"calendar": stores.calendar_agent("bs-cal", expires=SOON)}) + stores.principal("gone", {"calendar": stores.calendar_agent("gone-cal", revoked=True)}, expires=PAST) + stores.principal("off", {"calendar": stores.calendar_agent("off-cal", revoked=True)}, enabled=False) + ids = lambda *a: [row["id"] for row in listing(stores, capsys, *a)[1]["principals"]] # noqa: E731 + assert ids() == ids("--all") == ["live", "soon", "backend-soon", "gone", "off"] + assert ids("--valid") == ["live", "soon", "backend-soon"] + assert ids("--expiring") == ["soon", "backend-soon", "gone"] + assert ids("--principal", "soon") == ["soon"] + + +# spec: ANYM-494 +@pytest.mark.parametrize("case,expected", [ + ("missing", {"code": "credential_missing", "principal": "agent", "backend": "messaging", "public_id": None}), + ("revoked", {"code": "credential_revoked", "principal": "agent", "backend": "calendar", "public_id": "agent-cal"}), + ("expired", {"code": "credential_expired", "principal": "agent", "backend": "calls", "public_id": "agent-calls"}), + ("unbound-messaging", {"code": "unbound_key", "backend": "messaging", + "public_id": "00000000-0000-4000-8000-0000000000aa", "status": "live", + "backend_principal": "forgotten"}), + ("unbound-calendar", {"code": "unbound_key", "backend": "calendar", "public_id": "stray-cal", "status": "live", + "backend_principal": None}), + ("unbound-calls", {"code": "unbound_key", "backend": "calls", "public_id": "stray-calls", "status": "live", + "backend_principal": None}), + ("earlier", {"code": "backend_expires_first", "principal": "agent", "backend": "calendar", + "public_id": "agent-cal", "expires_at": SOON, "gateway_expires_at": LATER}), + ("shared", {"code": "shared_credential", "backend": "calendar", "public_id": "agent-cal", + "principals": ["agent", "twin"]}), + ("shared-archive", {"code": "shared_credential", "backend": "messaging", + "public_id": "00000000-0000-4000-8000-0000000000bb", "principals": ["agent", "twin"]}), +]) +def test_each_disagreement_between_stores_is_one_finding_and_strict_exits_1(stores, capsys, case, expected): + credentials = {} + if case == "missing": + credentials["messaging"] = stores.key("never-issued") + elif case in ("revoked", "earlier", "shared"): + credentials["calendar"] = stores.calendar_agent("agent-cal", revoked=case == "revoked", + expires=SOON if case == "earlier" else LATER) + elif case == "expired": + credentials["calls"] = stores.calls_agent("agent-calls", expires=PAST) + elif case == "unbound-messaging": # bound to no gateway principal and no token file on the host + stores.messaging_key("lost", "00000000-0000-4000-8000-0000000000aa", "forgotten", bound=False) + elif case == "unbound-calendar": + stores.calendar_agent("stray-cal", bound=False) + elif case == "unbound-calls": + stores.calls_agent("stray-calls") + elif case == "shared-archive": + stores.messaging_key("arch", "00000000-0000-4000-8000-0000000000bb", "archive", bound=False) + credentials["calls"] = stores.calls_agent("agent-calls", archive="arch") + stores.principal("agent", credentials) + if case == "shared": + stores.principal("twin", {"calendar": credentials["calendar"]}) + if case == "shared-archive": + stores.principal("twin", {"messaging": stores.key("arch")}) + code, view, _ = listing(stores, capsys) + assert view["findings"] == [expected] + assert code == 0 + assert admin.main(["--config", str(stores.root / "admin/config.json"), "key", "list", "--strict"]) == 1 + + +# spec: ANYM-494 +def test_strict_exits_0_when_the_stores_agree_and_a_disabled_principal_is_no_finding(stores, capsys): + healthy(stores) + stores.principal("retired", {"calendar": stores.calendar_agent("old-cal")}, enabled=False) + code, view, _ = listing(stores, capsys, "--strict") + assert code == 0 and view["findings"] == [] + + +# spec: ANYM-494 +@pytest.mark.parametrize("enabled,expires", [(False, LATER), (True, PAST), (False, PAST)], + ids=["disabled", "expired", "disabled-expired"]) +def test_a_disabled_or_expired_principal_still_reports_its_credential_findings(stores, capsys, enabled, expires): + healthy(stores) + stores.principal("retired", {"calendar": stores.calendar_agent("old-cal", revoked=True), + "messaging": stores.key("never-issued")}, enabled=enabled, expires=expires) + code, view, _ = listing(stores, capsys, "--strict") + assert code == 1 + assert view["findings"] == [ + {"code": "credential_revoked", "principal": "retired", "backend": "calendar", "public_id": "old-cal"}, + {"code": "credential_missing", "principal": "retired", "backend": "messaging", "public_id": None}] + + +# spec: ANYM-494 +@pytest.mark.parametrize("revoked,expires", [(True, LATER), (False, PAST)], ids=["revoked", "expired"]) +def test_a_revoked_or_expired_unbound_key_is_still_a_finding_with_its_status(stores, capsys, revoked, expires): + healthy(stores) + stores.messaging_key("old", "00000000-0000-4000-8000-0000000000cc", "gone", revoked=revoked, expires=expires, + bound=False) + stores.calendar_agent("old-cal", revoked=revoked, expires=expires, bound=False) + stores.calls_agent("old-calls", enabled=not revoked, expires=expires) + state = "revoked" if revoked else "expired" + _, view, _ = listing(stores, capsys) + assert view["findings"] == [ + {"code": "unbound_key", "backend": "calendar", "public_id": "old-cal", "status": state, + "backend_principal": None}, + {"code": "unbound_key", "backend": "calls", "public_id": "old-calls", "status": state, + "backend_principal": None}, + {"code": "unbound_key", "backend": "messaging", "public_id": "00000000-0000-4000-8000-0000000000cc", + "status": state, "backend_principal": "gone"}] + + +# spec: ANYM-494 +def test_principal_filter_keeps_the_unavailable_backend_it_uses_so_strict_fails(stores, capsys): + healthy(stores) + stores.principal("other", {"messaging": stores.messaging_key("o", "00000000-0000-4000-8000-0000000000dd", "o")}) + code, view, _ = listing(stores, capsys, "--principal", "claude-code", "--strict", broken=("calendar",)) + assert code == 1 + assert view["findings"] == [{"code": "backend_unavailable", "backend": "calendar", + "detail": "calendar: agent-info exited 3"}] + code, view, _ = listing(stores, capsys, "--principal", "other", "--strict", broken=("calendar",)) + assert code == 0 and view["findings"] == [] + + +# spec: ANYM-494 +def test_no_calls_registry_configured_is_a_finding_when_a_principal_holds_a_calls_key(stores, capsys): + healthy(stores) + stores.fake() + config = json.loads((stores.root / "admin/config.json").read_text()) + del config["calls_registry"] + private(stores.root / "admin/config.json", config) + code, view, _ = listing(stores, capsys, "--principal", "claude-code", "--strict") + calls = {c["backend"]: c for c in view["principals"][0]["credentials"]}["calls"] + assert code == 1 and calls["status"] == "unknown" and calls["note"] == "calls_registry_not_configured" + assert view["findings"] == [{"code": "backend_unavailable", "backend": "calls", + "detail": "calls: no registry configured"}] + + +# spec: ANYM-494 +def test_a_key_file_that_does_not_match_the_policy_pin_is_a_content_free_finding_not_live(stores, capsys): + healthy(stores) + other = stores.calendar_agent("other-cal") # a valid backend key, but not the one the policy pins + stores.principal("agent", {"calendar": dict(stores.key("agent-cal"), key_file=other["key_file"])}) + stores.calendar_agent("agent-cal") + _, view, out = listing(stores, capsys) + row = next(r for r in view["principals"] if r["id"] == "agent") + assert row["credentials"] == [{"backend": "calendar", "public_id": None, "expires_at": None, + "status": "unknown", "note": "credential_pin_mismatch"}] + assert {"code": "credential_pin_mismatch", "principal": "agent", "backend": "calendar"} in view["findings"] + for value in stores.tokens.values(): + assert value not in out.out and sha(value) not in out.out + + +# spec: ANYM-494 +@pytest.mark.parametrize("pin", ["é" * 64, "A" * 64, "0" * 63, None, 7], ids=["non-ascii", "upper", "short", "none", + "number"]) +def test_a_malformed_policy_pin_is_a_pin_mismatch_and_the_rest_still_lists(stores, capsys, pin): + healthy(stores) + stores.principal("agent", {"calendar": dict(stores.calendar_agent("agent-cal"), key_sha256=pin)}) + code, view, _ = listing(stores, capsys) + assert code == 0 and [r["id"] for r in view["principals"]] == ["claude-code", "agent"] + assert view["findings"] == [{"code": "credential_pin_mismatch", "principal": "agent", "backend": "calendar"}, + {"code": "unbound_key", "backend": "calendar", "public_id": "agent-cal", + "status": "live", "backend_principal": None}] + + +# spec: ANYM-494 +def test_one_principal_holding_a_key_directly_and_as_its_calls_archive_is_not_shared(stores, capsys): + stores.messaging_key("own", "00000000-0000-4000-8000-0000000000ee", "agent", bound=False) + stores.principal("agent", {"messaging": stores.key("own"), "calls": stores.calls_agent("agent-calls", archive="own")}) + _, view, _ = listing(stores, capsys, "--strict") + assert view["findings"] == [] + + +# spec: ANYM-494 +def test_a_failed_inventory_keeps_the_successful_lookups(stores, capsys): + healthy(stores) + _, view, _ = listing(stores, capsys, fail=("list-keys",)) + credentials = {c["backend"]: c for c in view["principals"][0]["credentials"]} + assert credentials["messaging"]["status"] == "live" and credentials["calls"]["archive"]["status"] == "live" + assert view["findings"] == [{"code": "backend_unavailable", "backend": "messaging", + "detail": "messaging: list-keys exited 3"}] + + +# spec: ANYM-494 +@pytest.mark.parametrize("interrupt", [False, True], ids=["timeout", "interrupt"]) +def test_a_timed_out_or_interrupted_command_stops_only_its_own_container(stores, monkeypatch, interrupt): + real, seen = admin.subprocess.run, [] + + def run(argv, **options): + seen.append(argv) + if argv[0] == "docker": + return admin.subprocess.CompletedProcess(argv, 0) + if interrupt: + raise KeyboardInterrupt + return real(argv, **options) + + monkeypatch.setattr(admin, "TIMEOUT", 0.2) + monkeypatch.setattr(admin.subprocess, "run", run) + command = admin.runner({"calendar": [sys.executable, "-c", "import time; time.sleep(30)", "--name", "{name}"]}) + if interrupt: + with pytest.raises(KeyboardInterrupt): + command("calendar", ["list-agents"]) + else: + with pytest.raises(admin.Unavailable, match="calendar: TimeoutExpired"): + command("calendar", ["list-agents"]) + name = seen[0][-2] + assert name.startswith("deedkit-admin-") + assert seen[1] == ["docker", "stop", "--time", "5", name] and len(seen) == 2 + + +# spec: ANYM-494 +def test_a_private_file_that_is_oversized_or_changes_while_read_is_refused_never_truncated(tmp_path, monkeypatch): + path = private(tmp_path / "key", "x" * 20) + with pytest.raises(admin.AdminError, match="unsafe private file"): + admin.read_private(path, 10) + real, calls = admin.os.fstat, [] + + def fstat(fd): + info = real(fd) + calls.append(fd) + if len(calls) == 1: + return info + values = list(info) + values[6] += 1 # st_size grew after the first look + return admin.os.stat_result(values) + + monkeypatch.setattr(admin.os, "fstat", fstat) + with pytest.raises(admin.AdminError, match="changed while read"): + admin.read_private(path) + + +# spec: ANYM-494 +def test_an_unavailable_backend_is_unknown_and_a_finding_never_a_crash(stores, capsys): + healthy(stores) + code, view, _ = listing(stores, capsys, broken=("calendar",)) + credentials = {c["backend"]: c for c in view["principals"][0]["credentials"]} + assert credentials["calendar"]["status"] == "unknown" and credentials["messaging"]["status"] == "live" + assert view["findings"] == [{"code": "backend_unavailable", "backend": "calendar", + "detail": "calendar: agent-info exited 3"}] + + +# spec: ANYM-494 +def test_list_is_read_only_and_no_token_or_digest_reaches_output_argv_or_environment(stores, capsys): + healthy(stores) + stores.fake() + before = stores.snapshot() + _, view, out = listing(stores, capsys) + text = admin.main(["--config", str(stores.root / "admin/config.json"), "key", "list"]) + shown = capsys.readouterr() + assert text == 0 and "claude-code" in shown.out + assert stores.snapshot() == before + calls = [json.loads(line) for line in (stores.root / "operator-state.json.calls").read_text().splitlines()] + assert {tuple(c["argv"][1:]) for c in calls} == {("key-info", "--key-file", "/dev/stdin"), ("list-keys", "--all"), + ("agent-info", "--key-file", "/dev/stdin"), ("list-agents",)} + seen = out.out + out.err + shown.out + shown.err + json.dumps(calls) + for value in stores.tokens.values(): + for secret in (value, sha(value)): + assert secret not in seen + + +# spec: ANYM-494 +def test_label_lives_only_in_the_private_admin_file_and_shows_in_the_list(stores, capsys): + healthy(stores) + config = stores.fake() + policy, registry = Path(stores.config["policy"]).read_bytes(), Path(stores.config["calls_registry"]).read_bytes() + assert admin.main(["--config", str(config), "key", "label", "claude-code", "claude.ai connector"]) == 0 + capsys.readouterr() + labels = Path(stores.config["labels"]) + assert labels.stat().st_mode & 0o777 == 0o600 + assert json.loads(labels.read_text()) == {"version": 1, "labels": {"claude-code": "claude.ai connector"}} + _, view, _ = listing(stores, capsys) + assert view["principals"][0]["label"] == "claude.ai connector" + assert Path(stores.config["policy"]).read_bytes() == policy + assert Path(stores.config["calls_registry"]).read_bytes() == registry + for path in stores.root.rglob("*"): + if path.is_file() and path != labels and not path.name.startswith("fake_operator"): + assert "claude.ai connector" not in path.read_text(errors="replace") + + +# spec: ANYM-494 +@pytest.mark.parametrize("principal,text", [("claude-code", ""), ("claude-code", "x" * 81), + ("claude-code", "tab\there"), ("nobody", "a label")]) +def test_label_refuses_bad_text_and_unknown_principals_and_writes_nothing(stores, capsys, principal, text): + healthy(stores) + config = stores.fake() + assert admin.main(["--config", str(config), "key", "label", principal, text]) == 2 + assert not Path(stores.config["labels"]).exists() + assert "deedkit admin:" in capsys.readouterr().err + + +# spec: ANYM-494 +def test_the_deedkit_launcher_routes_admin_to_the_admin_cli(stores, capsys, monkeypatch): + from scripts import deedkit + + healthy(stores) + config = stores.fake() + monkeypatch.syspath_prepend(str(Path(deedkit.__file__).parent)) + with pytest.raises(SystemExit) as done: + deedkit.main(["admin", "--config", str(config), "key", "list", "--json"]) + assert done.value.code == 0 + assert [row["id"] for row in json.loads(capsys.readouterr().out)["principals"]] == ["claude-code"] +