From 75a263362c6ea6e54574c1d24d226bfbdadd1495 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sun, 26 Jul 2026 17:22:05 -0700 Subject: [PATCH 1/3] fix(cli): make the documented invocation the real one Every command was attached to a redundant second `manifest` group, so the real invocation was `manifest manifest verify signed.json` while the README, the docs site, the PyPI description, and this module's own docstring all printed `manifest verify signed.json`. Anyone following the published quickstart hit `Error: No such command 'keygen'` on the first step. tests/test_cli.py invoked the nested form, which is why CI stayed green over a CLI nobody could drive as documented. Attach the commands to the top-level group. The nested spelling still resolves, is hidden from --help, and prints a deprecation warning; it goes away in 1.0. Also makes help output readable: \b markers stop click rewrapping the example blocks onto one line, and -o/--output, --enforce-hitl and --enforce-attestation carry help text instead of appearing bare. Co-Authored-By: Claude Opus 5 (1M context) --- python/src/agent_manifest/cli.py | 66 +++++++++++++++++++++++--------- python/tests/test_cli.py | 53 ++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 25 deletions(-) diff --git a/python/src/agent_manifest/cli.py b/python/src/agent_manifest/cli.py index 77a899e..b3d470f 100644 --- a/python/src/agent_manifest/cli.py +++ b/python/src/agent_manifest/cli.py @@ -88,11 +88,6 @@ def cli() -> None: """Agent Manifest SDK CLI.""" -@cli.group() -def manifest() -> None: - """Manage Agent Manifests.""" - - def _make_uuid7() -> str: """Generate a UUID v7 (time-ordered) per RFC 9562.""" import time @@ -109,7 +104,7 @@ def _make_uuid7() -> str: return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}" -@manifest.command("create") +@cli.command("create") @click.argument("config", type=click.Path(exists=True)) @click.option("--output", "-o", default=None, help="Write output to file (default: stdout)") def create(config: str, output: Optional[str]) -> None: @@ -118,6 +113,7 @@ def create(config: str, output: Optional[str]) -> None: CONFIG must be a JSON file with at minimum: agent_id, issuer, issued_at, expires_at, and an artifacts block. +  Example: manifest create config.json -o draft.json """ @@ -134,16 +130,17 @@ def create(config: str, output: Optional[str]) -> None: _write(data, output) -@manifest.command("sign") +@cli.command("sign") @click.argument("manifest_file", type=click.Path(exists=True)) @click.option("--key", "-k", required=True, help="Path to raw 32-byte Ed25519 private key (hex file)") -@click.option("--output", "-o", default=None) +@click.option("--output", "-o", default=None, help="Write output to file (default: stdout)") def sign(manifest_file: str, key: str, output: Optional[str]) -> None: """Sign a draft manifest with Ed25519. KEY must be a file containing the 64-hex-character (32-byte) Ed25519 private key seed. +  Example: manifest sign draft.json --key private.hex -o signed.json """ @@ -173,15 +170,17 @@ def sign(manifest_file: str, key: str, output: Optional[str]) -> None: _write(data, output) -@manifest.command("keygen") +@cli.command("keygen") @click.option("--output-dir", "-d", default=".", help="Directory to write key files") def keygen(output_dir: str) -> None: """Generate a new Ed25519 key pair for manifest signing. + \b Writes: - private.hex — 64-hex private key seed (keep secret, mode 0600) - public.hex — 64-hex public key bytes + private.hex - 64-hex private key seed (keep secret, mode 0600) + public.hex - 64-hex public key bytes +  Example: manifest keygen -d ./keys/ """ @@ -210,19 +209,20 @@ def keygen(output_dir: str) -> None: click.echo("Keep private.hex secret.", err=True) -@manifest.command("attest") +@cli.command("attest") @click.argument("manifest_file", type=click.Path(exists=True)) @click.option("--provider", "-p", default="auto", type=click.Choice(["auto", "azure-cvm", "tpm", "sev-snp", "tdx", "opaque", "software"]), help="Attestation provider (default: auto)") @click.option("--level", default=0, type=int, help="Minimum conformance level (0-3)") -@click.option("--output", "-o", default=None) +@click.option("--output", "-o", default=None, help="Write output to file (default: stdout)") def attest(manifest_file: str, provider: str, level: int, output: Optional[str]) -> None: """Extend the manifest hash into hardware and append the attestation block. For TPM: requires tpm2-tools (apt-get install tpm2-tools). For swtpm in CI: set TPM2TOOLS_TCTI=swtpm: before running. +  Example: manifest attest signed.json --provider tpm --level 1 -o attested.json """ @@ -269,13 +269,15 @@ def attest(manifest_file: str, provider: str, level: int, output: Optional[str]) sys.exit(1) -@manifest.command("verify") +@cli.command("verify") @click.argument("manifest_file", type=click.Path(exists=True)) -@click.option("--enforce-hitl", is_flag=True, default=False) -@click.option("--enforce-attestation", is_flag=True, default=False) +@click.option("--enforce-hitl", is_flag=True, default=False, + help="Fail unless a required HITL approval is present and unexpired") +@click.option("--enforce-attestation", is_flag=True, default=False, + help="Fail unless the attestation report matches the manifest hash") @click.option("--crl-path", default=None, help="Path to a FileCRL JSON-Lines file for revocation checks") @click.option("--public-key", default=None, help="Path to a trusted raw Ed25519 public key hex file") -@click.option("--output", "-o", default=None) +@click.option("--output", "-o", default=None, help="Write output to file (default: stdout)") def verify( manifest_file: str, enforce_hitl: bool, @@ -291,6 +293,7 @@ def verify( Use --crl-path to load a revocation list and check for revoked manifests. +  Example: manifest verify attested.json --crl-path revocations.jsonl """ @@ -364,17 +367,18 @@ def get_record(self, manifest_id: str) -> Optional[RevocationRecord]: ) -@manifest.command("revoke") +@cli.command("revoke") @click.argument("manifest_id") @click.option("--reason", "-r", required=True, help="Reason for revocation") @click.option("--revoked-by", required=True, help="Identity of revoking authority (DID or email)") -@click.option("--output", "-o", default=None) +@click.option("--output", "-o", default=None, help="Write output to file (default: stdout)") def revoke(manifest_id: str, reason: str, revoked_by: str, output: Optional[str]) -> None: """Generate a revocation record for a manifest ID. The record JSON can be submitted to your revocation registry or passed to a RevocationStore instance in the verification endpoint. +  Example: manifest revoke 018f4a3b-... --reason "key compromise" --revoked-by security@example.com """ @@ -394,6 +398,30 @@ def revoke(manifest_id: str, reason: str, revoked_by: str, output: Optional[str] click.echo(f"Revocation record created for {manifest_id}", err=True) +TOP_LEVEL_COMMANDS = ("create", "sign", "keygen", "attest", "verify", "revoke") + + +@cli.group("manifest", hidden=True) +def manifest_alias() -> None: + """Deprecated: every command is available at the top level. + + Releases up to 0.5.0 nested the commands under a redundant ``manifest`` + group, so the real invocation was ``manifest manifest verify`` while every + document said ``manifest verify``. The documented form is now the real one. + This alias keeps the old spelling working for existing scripts. + """ + click.echo( + "Warning: 'manifest manifest ' is deprecated and will be " + "removed in 1.0. Use 'manifest ' instead.", + err=True, + ) + + +for _name in TOP_LEVEL_COMMANDS: + _command = cli.commands[_name] + manifest_alias.add_command(_command, _name) + + def main() -> None: cli() diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py index 39ae177..cbcf240 100644 --- a/python/tests/test_cli.py +++ b/python/tests/test_cli.py @@ -70,7 +70,7 @@ def test_cli_verify_without_public_key_is_unverifiable(tmp_path): keypair = generate_ed25519() signed_path = _write_signed_manifest(tmp_path, keypair) - result = CliRunner().invoke(cli, ["manifest", "verify", str(signed_path)]) + result = CliRunner().invoke(cli, ["verify", str(signed_path)]) payload = _json_stdout(result) assert result.exit_code == 1 @@ -85,7 +85,7 @@ def test_cli_verify_with_matching_public_key_is_valid(tmp_path): result = CliRunner().invoke( cli, - ["manifest", "verify", str(signed_path), "--public-key", str(public_path)], + ["verify", str(signed_path), "--public-key", str(public_path)], ) payload = _json_stdout(result) @@ -102,7 +102,7 @@ def test_cli_verify_with_wrong_public_key_is_mismatch(tmp_path): result = CliRunner().invoke( cli, - ["manifest", "verify", str(signed_path), "--public-key", str(public_path)], + ["verify", str(signed_path), "--public-key", str(public_path)], ) payload = _json_stdout(result) @@ -119,7 +119,7 @@ def test_cli_verify_with_malformed_public_key_fails_cleanly(tmp_path): result = CliRunner().invoke( cli, - ["manifest", "verify", str(signed_path), "--public-key", str(public_path)], + ["verify", str(signed_path), "--public-key", str(public_path)], ) assert result.exit_code != 0 @@ -134,7 +134,7 @@ def test_cli_verify_with_missing_public_key_file_fails_cleanly(tmp_path): result = CliRunner().invoke( cli, - ["manifest", "verify", str(signed_path), "--public-key", str(public_path)], + ["verify", str(signed_path), "--public-key", str(public_path)], ) assert result.exit_code != 0 @@ -157,7 +157,7 @@ def test_cli_verify_bound_artifacts_without_runtime_hashes_qualifies_valid(tmp_p result = CliRunner().invoke( cli, - ["manifest", "verify", str(signed_path), "--public-key", str(public_path)], + ["verify", str(signed_path), "--public-key", str(public_path)], ) # Signature is genuinely valid (exit 0), but the status must be qualified. @@ -170,3 +170,44 @@ def test_cli_verify_bound_artifacts_without_runtime_hashes_qualifies_valid(tmp_p assert "Result: VALID\n" not in result.output # And the result payload carries the machine-readable warning too. assert any("artifact bindings NOT verified" in w for w in payload["warnings"]) + + +# --------------------------------------------------------------------------- +# Command surface: the documented invocation must be the real one +# --------------------------------------------------------------------------- + + +def test_documented_commands_are_top_level(): + # Releases up to 0.5.0 nested every command under a redundant `manifest` + # group, so `manifest verify signed.json` (the form printed in the README, + # the docs site, and the PyPI description) exited with "No such command". + from agent_manifest.cli import TOP_LEVEL_COMMANDS + + assert set(TOP_LEVEL_COMMANDS) <= set(cli.commands) + for name in TOP_LEVEL_COMMANDS: + result = CliRunner().invoke(cli, [name, "--help"]) + assert result.exit_code == 0, f"`manifest {name} --help` failed" + + +def test_deprecated_nested_invocation_still_works(tmp_path): + keypair = generate_ed25519() + signed_path = _write_signed_manifest(tmp_path, keypair) + public_path = _write_public_key(tmp_path, keypair) + + result = CliRunner().invoke( + cli, + ["manifest", "verify", str(signed_path), "--public-key", str(public_path)], + ) + + assert result.exit_code == 0 + assert _json_stdout(result)["result"] == "VALID" + assert "deprecated" in result.stderr + + +def test_deprecated_group_is_hidden_from_help(): + result = CliRunner().invoke(cli, ["--help"]) + + assert result.exit_code == 0 + assert "verify" in result.output + # The alias exists for old scripts but must not be advertised. + assert "\n manifest " not in result.output From 6eca5887528259ab276eb2e9d6dedd1dd92e210b Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sun, 26 Jul 2026 17:22:05 -0700 Subject: [PATCH 2/3] docs(cli): generate the CLI reference from the CLI The hand-written reference had drifted into fiction. It documented `manifest keygen --out/--print-pub`, `manifest verify --revocation-url/--min-slsa-level`, `manifest create --agent-id/--issuer/--model/--ttl-hours` and `manifest revoke --crl-file/--key-file`, none of which exist, while omitting the options that do. Render the page from click's own help text, following the same committed-generated-file pattern as tests/vectors. test_cli_reference.py regenerates it in memory and fails if the committed copy drifts, so a change to a command cannot land with stale docs. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api-reference/cli.md | 143 +++++++++++++++++++---------- python/tests/test_cli_reference.py | 59 ++++++++++++ scripts/gen_cli_reference.py | 76 +++++++++++++++ 3 files changed, 229 insertions(+), 49 deletions(-) create mode 100644 python/tests/test_cli_reference.py create mode 100644 scripts/gen_cli_reference.py diff --git a/docs/api-reference/cli.md b/docs/api-reference/cli.md index 84c0b5f..999b48d 100644 --- a/docs/api-reference/cli.md +++ b/docs/api-reference/cli.md @@ -1,3 +1,5 @@ + + # CLI reference The `manifest` command is installed with the `cli` extra. @@ -6,99 +8,142 @@ The `manifest` command is installed with the `cli` extra. pip install "agent-manifest[cli]" ``` +Every command is invoked directly: `manifest verify signed.json`. Releases up to +0.5.0 nested them under a second `manifest` group; that spelling still works and +prints a deprecation warning. + ## Commands ### manifest create -Create and sign a new agent manifest. +Create a draft manifest from a JSON config file. ``` -manifest create [OPTIONS] +Usage: manifest create [OPTIONS] CONFIG + + Create a draft manifest from a JSON config file. + + CONFIG must be a JSON file with at minimum: agent_id, issuer, issued_at, expires_at, + and an artifacts block. + + Example: + manifest create config.json -o draft.json Options: - --agent-id TEXT SPIFFE URI identifying this agent role [required] - --issuer TEXT SPIFFE URI of the signing authority [required] - --model TEXT Model identifier (e.g. gpt-4o-2024-08-06) - --prompt-file PATH Path to the system prompt file (hashed, not stored) - --ttl-hours INTEGER Manifest validity window in hours [default: 8] - --crypto-profile TEXT standard | post-quantum | hybrid [default: standard] - --out PATH Output path for the signed manifest JSON - --help Show this message and exit. + -o, --output TEXT Write output to file (default: stdout) + --help Show this message and exit. ``` ### manifest sign -Sign an existing manifest JSON with a key loaded from a file or environment variable. +Sign a draft manifest with Ed25519. ``` -manifest sign [OPTIONS] MANIFEST_FILE +Usage: manifest sign [OPTIONS] MANIFEST_FILE + + Sign a draft manifest with Ed25519. + + KEY must be a file containing the 64-hex-character (32-byte) Ed25519 private key seed. + + Example: + manifest sign draft.json --key private.hex -o signed.json Options: - --key-file PATH Path to Ed25519 private key (base64url, no padding) - --key-env TEXT Environment variable holding the private key - --out PATH Output path (defaults to overwriting MANIFEST_FILE) - --help Show this message and exit. + -k, --key TEXT Path to raw 32-byte Ed25519 private key (hex file) [required] + -o, --output TEXT Write output to file (default: stdout) + --help Show this message and exit. ``` -### manifest verify +### manifest keygen -Verify a manifest against its signature and optional runtime context. +Generate a new Ed25519 key pair for manifest signing. ``` -manifest verify [OPTIONS] MANIFEST_FILE +Usage: manifest keygen [OPTIONS] + + Generate a new Ed25519 key pair for manifest signing. + + Writes: + private.hex - 64-hex private key seed (keep secret, mode 0600) + public.hex - 64-hex public key bytes + + Example: + manifest keygen -d ./keys/ Options: - --revocation-url TEXT CRL endpoint to check for revocation - --public-key PATH Path to a trusted raw Ed25519 public key hex file - --enforce-hitl Fail if no valid HITL approval is present - --enforce-attestation Fail if no attestation report is present - --min-slsa-level INT Minimum SLSA level required [default: 0] - --help Show this message and exit. + -d, --output-dir TEXT Directory to write key files + --help Show this message and exit. ``` -For local signature verification, pass the public key generated by -`manifest keygen`. Without a trusted public key, signed manifests fail closed as -`UNVERIFIABLE`. - -### manifest revoke +### manifest attest -Append a signed revocation record to a local CRL file. +Extend the manifest hash into hardware and append the attestation block. ``` -manifest revoke [OPTIONS] MANIFEST_ID +Usage: manifest attest [OPTIONS] MANIFEST_FILE + + Extend the manifest hash into hardware and append the attestation block. + + For TPM: requires tpm2-tools (apt-get install tpm2-tools). For swtpm in CI: set + TPM2TOOLS_TCTI=swtpm: before running. + + Example: + manifest attest signed.json --provider tpm --level 1 -o attested.json Options: - --crl-file PATH Path to the JSON-Lines CRL file [required] - --reason TEXT Revocation reason [required] - --revoked-by TEXT SPIFFE URI or email of the revoking authority [required] - --key-file PATH Path to the signing key [required] - --help Show this message and exit. + -p, --provider [auto|azure-cvm|tpm|sev-snp|tdx|opaque|software] + Attestation provider (default: auto) + --level INTEGER Minimum conformance level (0-3) + -o, --output TEXT Write output to file (default: stdout) + --help Show this message and exit. ``` -### manifest keygen +### manifest verify -Generate a new Ed25519 key pair and write the private key to a file. +Verify a manifest against the local verification engine. ``` -manifest keygen [OPTIONS] +Usage: manifest verify [OPTIONS] MANIFEST_FILE + + Verify a manifest against the local verification engine. + + Prints the VerificationResult as JSON. Exits with code 0 on VALID, 1 on any other + result. + + Use --crl-path to load a revocation list and check for revoked manifests. + + Example: + manifest verify attested.json --crl-path revocations.jsonl Options: - --out PATH Output path for the private key (base64url, no padding) - --print-pub Print the public key to stdout after generation - --help Show this message and exit. + --enforce-hitl Fail unless a required HITL approval is present and unexpired + --enforce-attestation Fail unless the attestation report matches the manifest hash + --crl-path TEXT Path to a FileCRL JSON-Lines file for revocation checks + --public-key TEXT Path to a trusted raw Ed25519 public key hex file + -o, --output TEXT Write output to file (default: stdout) + --help Show this message and exit. ``` -### manifest attest +### manifest revoke -Run the auto-provider and print the attestation report as JSON. +Generate a revocation record for a manifest ID. ``` -manifest attest [OPTIONS] +Usage: manifest revoke [OPTIONS] MANIFEST_ID + + Generate a revocation record for a manifest ID. + + The record JSON can be submitted to your revocation registry or passed to a + RevocationStore instance in the verification endpoint. + + Example: + manifest revoke 018f4a3b-... --reason "key compromise" --revoked-by security@example.com Options: - --provider TEXT tpm | sev-snp | tdx | opaque | auto [default: auto] - --manifest-file PATH Manifest to extend into the attestation register - --help Show this message and exit. + -r, --reason TEXT Reason for revocation [required] + --revoked-by TEXT Identity of revoking authority (DID or email) [required] + -o, --output TEXT Write output to file (default: stdout) + --help Show this message and exit. ``` ## Source diff --git a/python/tests/test_cli_reference.py b/python/tests/test_cli_reference.py new file mode 100644 index 0000000..34759e1 --- /dev/null +++ b/python/tests/test_cli_reference.py @@ -0,0 +1,59 @@ +"""The committed CLI reference page must match the CLI it documents. + +``docs/api-reference/cli.md`` is generated by ``scripts/gen_cli_reference.py``. +Before it was generated it had drifted far enough to document options that never +existed (``manifest keygen --print-pub``, ``manifest verify --min-slsa-level``). +This test regenerates the page in memory and fails if the committed copy differs, +so a change to a command or an option cannot land with stale docs. + +To fix a failure here, run from the repository root: + + python scripts/gen_cli_reference.py + +and commit the result. +""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +GENERATOR = REPO_ROOT / "scripts" / "gen_cli_reference.py" +REFERENCE = REPO_ROOT / "docs" / "api-reference" / "cli.md" + + +def _load_generator(): + # Absent when the tests run from an sdist, which ships src/ and tests/ only. + if not GENERATOR.is_file(): + pytest.skip("generator not present (running outside a source checkout)") + spec = importlib.util.spec_from_file_location("gen_cli_reference", GENERATOR) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_cli_reference_matches_the_cli(): + generator = _load_generator() + if not REFERENCE.is_file(): + pytest.skip("reference page not present") + + expected = generator.render() + actual = REFERENCE.read_text(encoding="utf-8") + + assert actual == expected, ( + "docs/api-reference/cli.md is out of date with the CLI. " + "Run: python scripts/gen_cli_reference.py" + ) + + +def test_reference_documents_every_command(): + generator = _load_generator() + rendered = generator.render() + + for name in generator.TOP_LEVEL_COMMANDS: + assert f"### manifest {name}" in rendered + # The usage line proves the command is reachable without a group prefix. + assert f"Usage: manifest {name}" in rendered diff --git a/scripts/gen_cli_reference.py b/scripts/gen_cli_reference.py new file mode 100644 index 0000000..442356f --- /dev/null +++ b/scripts/gen_cli_reference.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Generate docs/api-reference/cli.md from the CLI itself. + +The reference page used to be written by hand and had drifted badly from the +code: it documented options that never existed (``manifest keygen --print-pub``, +``manifest verify --min-slsa-level``) and omitted the ones that do. Rendering it +from click's own help text means the page cannot describe a flag the CLI does +not have. + +Run from the repository root: + + python scripts/gen_cli_reference.py + +``tests/test_cli_reference.py`` regenerates the page in memory and fails if the +committed copy differs, so CI catches a CLI change that was not reflected in the +docs. Regenerate and commit the result whenever the command surface changes. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "python" / "src")) + +import click # noqa: E402 + +from agent_manifest.cli import TOP_LEVEL_COMMANDS, cli # noqa: E402 + +OUTPUT_PATH = REPO_ROOT / "docs" / "api-reference" / "cli.md" + +HEADER = """ + +# CLI reference + +The `manifest` command is installed with the `cli` extra. + +```bash +pip install "agent-manifest[cli]" +``` + +Every command is invoked directly: `manifest verify signed.json`. Releases up to +0.5.0 nested them under a second `manifest` group; that spelling still works and +prints a deprecation warning. + +## Commands +""" + +FOOTER = """## Source + +::: agent_manifest.cli +""" + + +def _help_text(name: str) -> str: + command = cli.commands[name] + ctx = click.Context(command, info_name=f"manifest {name}", terminal_width=88) + return command.get_help(ctx).rstrip() + + +def render() -> str: + sections = [HEADER] + for name in TOP_LEVEL_COMMANDS: + summary = (cli.commands[name].help or "").strip().splitlines()[0] + sections.append(f"\n### manifest {name}\n\n{summary}\n\n```\n{_help_text(name)}\n```\n") + sections.append("\n" + FOOTER) + return "".join(sections) + + +def main() -> None: + OUTPUT_PATH.write_text(render(), encoding="utf-8") + print(f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)} ({len(TOP_LEVEL_COMMANDS)} commands)") + + +if __name__ == "__main__": + main() From 2ce5d29be517987a4643f2959e20ca4d343c6cbe Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sun, 26 Jul 2026 17:22:05 -0700 Subject: [PATCH 3/3] docs: fix CLI examples that could not run; release 0.6.0 key-rotation.md used `manifest keygen --out ... --print-pub`, neither flag of which exists. index.md and python/README.md printed `manifest verify` without --public-key next to a `# VALID` comment, but with no trusted key the verifier fails closed as UNVERIFIABLE and exits 1. Every CLI invocation in the docs has now been executed against a clean install of the 0.6.0 wheel. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 12 ++++++++++++ docs/index.md | 2 +- docs/operations/key-rotation.md | 13 ++++++++----- python/README.md | 5 ++++- python/pyproject.toml | 2 +- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c5139..a9faee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,26 @@ All notable changes to Agent Manifest are documented here. Format follows [Keep ## [Unreleased] +## [0.6.0] — 2026-07-26 + +Fixes the CLI that every document described but that nobody could run, and closes a signature-downgrade gap in the verifier. + ### Fixed +**[SDK]** **The documented CLI invocation now works.** Every command was nested under a redundant second `manifest` group, so the real invocation was `manifest manifest verify signed.json` while the README, the docs site, the PyPI description, and the CLI's own module docstring all printed `manifest verify signed.json`. Running the published quickstart failed at the first step with `Error: No such command 'keygen'`. `tests/test_cli.py` used the nested form, so CI never caught it. Commands are now attached to the top-level group as documented; the nested spelling still works, is hidden from `--help`, and prints a deprecation warning (removal in 1.0). `test_documented_commands_are_top_level` guards the surface. + **[SDK]** `verify_manifest()` now cross-checks the signed `crypto_profile` against `signature.algorithm` and fails closed on a downgrade (spec 4.2). `crypto_profile` is inside the signing pre-image, but the whole `signature` block is excluded from it (spec 3.6), so the algorithm identifier could previously be rewritten without disturbing the signed bytes: a manifest declaring the post-quantum profile verified `VALID` on a classical-only Ed25519 signature. The check runs independently of `trusted_keys` (the downgrade is a property of the manifest, not of the verifier's key material) and is one-directional: it rejects a signature weaker than the declared profile requires and permits a stronger one, so an issuer dual-signing ahead of the profile flip is not flagged. New conformance vector `AM-VEC-020`. ### Documentation **[SDK]** `docs/getting-started.md`: new "Watch it catch a change" step. Shows the two ways verification fails and how they differ — an edit to the signed record (signature fails, `signature_verified: false`) versus runtime drift against an intact signature (declared-vs-actual binding fails, `system_prompt: MISMATCH`) — and names the boot-time boundary, pointing at `attest_runtime_state()` for freshness. All printed values are copied from real runs. +**[SDK]** `docs/api-reference/cli.md` is now generated from the CLI by `scripts/gen_cli_reference.py` instead of hand-written. The hand-written page had drifted into fiction: it documented `manifest keygen --out/--print-pub`, `manifest verify --revocation-url/--min-slsa-level`, `manifest create --agent-id/--issuer/--model/--ttl-hours`, and `manifest revoke --crl-file/--key-file`, none of which exist, while omitting the options that do. `test_cli_reference.py` fails if the committed page drifts from the CLI again. + +**[SDK]** CLI help output is readable: `\b` markers keep the example blocks from being rewrapped into one line, and `-o/--output`, `--enforce-hitl`, and `--enforce-attestation` have help text instead of appearing bare. + +**[SDK]** Corrected CLI examples that could not have worked: `docs/operations/key-rotation.md` used the non-existent `manifest keygen --out ... --print-pub`, and `docs/index.md` plus `python/README.md` printed `manifest verify` without `--public-key`, which fails closed as `UNVERIFIABLE` and exits 1 rather than the `VALID` shown. + **[SDK]** `LIMITATIONS.md`: document that **Azure TDX is not supported for offline attestation** (hardware-confirmed). Azure runs TDX behind the Hyper-V paravisor, so the guest gets no signed DCAP quote — only a MAC'd `TDREPORT` via the vTPM — and rooting that as genuine silicon needs a networked service (Azure MAA). Offline TDX attestation is supported on non-paravisor guests (e.g. GCP C3); on Azure use SEV-SNP (`AzureCVMProvider`). Azure-MAA TDX support is tracked as a follow-up. ## [0.5.0] — 2026-07-21 diff --git a/docs/index.md b/docs/index.md index f4f3b02..ca569df 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,7 @@ pip install "agent-manifest[cli]" manifest keygen -d ./keys/ manifest create config.json -o draft.json manifest sign draft.json --key keys/private.hex -o signed.json -manifest verify signed.json # VALID +manifest verify signed.json --public-key keys/public.hex # VALID ``` ## The agent attestation gap diff --git a/docs/operations/key-rotation.md b/docs/operations/key-rotation.md index 53b5734..4920241 100644 --- a/docs/operations/key-rotation.md +++ b/docs/operations/key-rotation.md @@ -29,11 +29,14 @@ Before starting: ## Step 1 - Generate a new key pair ```bash -# Generate a new Ed25519 key pair -manifest keygen --out /path/to/new-signing-key.b64url --print-pub - -# The printed public key goes into your trust anchor configuration -# The private key stays in the secrets manager - never in source control +# Generate a new Ed25519 key pair into a directory +manifest keygen -d /path/to/new-keys/ + +# Writes new-keys/private.hex (mode 0600) and new-keys/public.hex, and prints +# the new key_id to stderr. +# +# The public key goes into your trust anchor configuration +# The private key moves to the secrets manager - never into source control ``` Or in Python: diff --git a/python/README.md b/python/README.md index 33ace03..b72fa66 100644 --- a/python/README.md +++ b/python/README.md @@ -120,10 +120,13 @@ manifest keygen -d ./keys/ manifest create config.json -o draft.json manifest sign draft.json --key keys/private.hex -o signed.json manifest attest signed.json --provider auto --level 1 -o attested.json -manifest verify attested.json +manifest verify attested.json --public-key keys/public.hex manifest revoke --reason "key compromise" --revoked-by security@example.com ``` +Without `--public-key` the verifier has no trusted issuer key, so a signed +manifest fails closed as `UNVERIFIABLE` and the command exits 1. + ## Cryptography - **Standard profile**: Ed25519 (RFC 8032), SHA-256, RFC 8785 canonical JSON diff --git a/python/pyproject.toml b/python/pyproject.toml index d799b18..3f13762 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agent-manifest" -version = "0.5.0" +version = "0.6.0" description = "Agent Manifest SDK — cryptographically anchor all 10 artifacts defining an AI agent at deployment" readme = "README.md" requires-python = ">=3.11"