Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 72 additions & 28 deletions agentdescent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3386,32 +3386,64 @@ def cmd_release(a: argparse.Namespace) -> int:
"""``agentdescent release`` -- evidence-gated, signed release receipt."""
from .release_surface import (
ReleaseSurfaceError, load_rules, release_payload, release_text,
resolve_secret,
resolve_secret, verify_release,
)

verb = getattr(a, "release_cmd", None)
try:
rules = load_rules(
a.rules, min_effect=a.min_effect, max_risk=a.max_risk)
secret = resolve_secret(
secret_env=a.secret_env, secret_file=a.secret_file)
metadata = None
if a.metadata is not None:
try:
metadata = json.loads(a.metadata)
except ValueError as error:
if verb == "verify":
if not a.receipt:
raise ReleaseSurfaceError(
f"--metadata is not valid JSON: {error}") from None
if not isinstance(metadata, dict):
raise ReleaseSurfaceError("--metadata must be a JSON object")
payload = release_payload(
a.before, a.after, artifact_id=a.artifact_id,
artifact_hash=a.artifact_hash, key_id=a.key_id, secret=secret,
rules=rules, lineage=a.lineage, parents=a.parents,
contaminated=a.contamination, approved=a.approval, risk=a.risk,
min_effect=a.min_effect, alpha=a.alpha, seed=a.seed,
proxy_gains=a.proxy_gains, oracle_gains=a.oracle_gains,
issuer=a.issuer, run_id=a.run_id, kind=a.kind,
metadata=metadata, out=a.out)
"release verify needs --receipt PATH")
ignored = [
flag for flag, value in (
("--before", a.before), ("--after", a.after),
("--artifact-id", a.artifact_id),
("--artifact-hash", a.artifact_hash), ("--out", a.out))
if value
]
if ignored:
raise ReleaseSurfaceError(
"release verify does not take " + ", ".join(ignored)
+ "; use the release command to create a receipt")
payload = verify_release(
a.receipt, key_id=a.key_id, secret_env=a.secret_env,
secret_file=a.secret_file, lineage=a.lineage)
else:
missing = [
flag for flag, value in (
("--before", a.before), ("--after", a.after),
("--artifact-id", a.artifact_id),
("--artifact-hash", a.artifact_hash),
("--key-id", a.key_id))
if not value
]
if missing:
raise ReleaseSurfaceError(
"release needs " + ", ".join(missing)
+ " (or pass `release verify --receipt ...`)")
rules = load_rules(
a.rules, min_effect=a.min_effect, max_risk=a.max_risk)
secret = resolve_secret(
secret_env=a.secret_env, secret_file=a.secret_file)
metadata = None
if a.metadata is not None:
try:
metadata = json.loads(a.metadata)
except ValueError as error:
raise ReleaseSurfaceError(
f"--metadata is not valid JSON: {error}") from None
if not isinstance(metadata, dict):
raise ReleaseSurfaceError("--metadata must be a JSON object")
payload = release_payload(
a.before, a.after, artifact_id=a.artifact_id,
artifact_hash=a.artifact_hash, key_id=a.key_id, secret=secret,
rules=rules, lineage=a.lineage, parents=a.parents,
contaminated=a.contamination, approved=a.approval, risk=a.risk,
min_effect=a.min_effect, alpha=a.alpha, seed=a.seed,
proxy_gains=a.proxy_gains, oracle_gains=a.oracle_gains,
issuer=a.issuer, run_id=a.run_id, kind=a.kind,
metadata=metadata, out=a.out)
except (ReleaseSurfaceError, OSError, ValueError) as error:
if getattr(a, "json", False):
_out({"ok": False, "error": str(error)}, as_json=True)
Expand All @@ -3420,9 +3452,15 @@ def cmd_release(a: argparse.Namespace) -> int:
return 2
if a.json:
_out(payload, as_json=True)
elif verb == "verify":
print(f"valid: {'yes' if payload['valid'] else 'no'}")
print(f"reason: {payload['reason']}")
print(f"artifact: {payload['artifact_id']}")
print(f"manifest: {payload['manifest_hash']}")
else:
print(release_text(payload))
return 0 if payload["released"] else 1
return 0 if (payload["valid"] if verb == "verify"
else payload["released"]) else 1


def safety_payload(archive: Optional[str] = None, *,
Expand Down Expand Up @@ -4105,15 +4143,21 @@ def build_parser() -> argparse.ArgumentParser:
s = sub.add_parser(
"release",
help="gate paired evidence and sign an accepted release receipt")
s.add_argument("--before", required=True, metavar="PATH",
s.add_argument(
"release_cmd", nargs="?", choices=("verify",), default=None,
metavar="{verify}",
help="verify a signed receipt instead of creating one")
s.add_argument("--receipt", metavar="PATH",
help="release receipt JSON to verify (release verify)")
s.add_argument("--before", metavar="PATH",
help="JSON object of task -> incumbent score, or '-' for stdin")
s.add_argument("--after", required=True, metavar="PATH",
s.add_argument("--after", metavar="PATH",
help="JSON object of task -> candidate score, or '-' for stdin")
s.add_argument("--artifact-id", required=True, dest="artifact_id",
s.add_argument("--artifact-id", dest="artifact_id",
help="the released artifact id")
s.add_argument("--artifact-hash", required=True, dest="artifact_hash",
s.add_argument("--artifact-hash", dest="artifact_hash",
help="content hash of the artifact (also the manifest identity)")
s.add_argument("--key-id", required=True, dest="key_id",
s.add_argument("--key-id", dest="key_id",
help="signing key id written into the manifest")
s.add_argument("--secret-env", dest="secret_env", default=None,
help="environment variable holding the HMAC secret "
Expand Down
4 changes: 4 additions & 0 deletions agentdescent/integrations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,10 @@ does not exist on the CLI/MCP path.
promotion writes the signed manifest and lineage. The signing secret is the
server's `AGENTDESCENT_RELEASE_KEY`, never a tool argument. Relay the verdict
and the reasons/missing list; do not present a deferral as a release.
- **`release_verify`** — read-only: verify a receipt that `release` wrote.
Recomputes the signature over the embedded manifest, so an edited hash or
evidence fails. `valid: false` is a normal result with `reason`; report it
and do not retry as if the tool failed.
- **`audit_trace`** — "where did this adopted rule's evidence come from?" for
one `run_id`. A ring with no store passed in is missing evidence, not a pass.

Expand Down
25 changes: 25 additions & 0 deletions agentdescent/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,14 @@ def rescan_allow_from_env(environ: Optional[Dict[str, str]] = None
"`secret_file`, never from a tool argument. A reject/defer is a normal "
"result with `released: false`; ask the user before treating any release as "
"final. Pass `lineage` to persist and extend the receipt graph."),
"release_verify": (
"Read-only: verify a signed release receipt written by `agentdescent "
"release --json`. Recomputes the HMAC over the embedded manifest, so an "
"edited artifact hash, evidence or signature fails; `valid: false` is a "
"normal result with a reason, not an error. Pass `key_id` to pin the "
"expected signer, and `lineage` to require the artifact node to carry the "
"same manifest hash. The signing secret is read only from the server's "
"`AGENTDESCENT_RELEASE_KEY`; the tool has no secret argument."),
"safety": (
"Read-only: is a consolidation archive (the rules meta runs adopted) safe to "
"keep seeding new runs from? Returns `verdict` (safe / caution / unsafe), a "
Expand Down Expand Up @@ -789,6 +797,17 @@ def release(self, before: Dict[str, float], after: Dict[str, float], *,
except (ReleaseSurfaceError, OSError, ValueError) as e:
return {"ok": False, "error": str(e)}

def release_verify(self, receipt: str, *,
key_id: Optional[str] = None,
lineage: Optional[str] = None) -> Dict[str, Any]:
from .release_surface import ReleaseSurfaceError, verify_release

try:
return verify_release(
receipt, key_id=key_id, lineage=lineage)
except (ReleaseSurfaceError, OSError, ValueError) as e:
return {"ok": False, "error": str(e)}

def _check_write(self, path: Optional[str], name: str) -> Optional[str]:
from .surface_common import ensure_write_path

Expand Down Expand Up @@ -1254,6 +1273,12 @@ def release(before: Dict[str, float], after: Dict[str, float],
proxy_gains=proxy_gains, oracle_gains=oracle_gains,
issuer=issuer, run_id=run_id, kind=kind, metadata=metadata)

@server.tool(description=TOOL_DESCRIPTIONS["release_verify"])
def release_verify(receipt: str, key_id: Optional[str] = None,
lineage: Optional[str] = None) -> Dict[str, Any]:
return t.release_verify(
receipt, key_id=key_id, lineage=lineage)

@server.tool(description=TOOL_DESCRIPTIONS["tenant"])
def tenant(spec: Dict[str, Any], dry_run: bool = False,
out: Optional[str] = None) -> Dict[str, Any]:
Expand Down
170 changes: 170 additions & 0 deletions agentdescent/release_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
from typing import Any, Dict, Mapping, Optional, Sequence, Tuple

from .lineage import LineageGraph, build_lineage
from .policy_release import (
ArtifactManifest, SignedEnvelope, verify_manifest,
)
from .promotion import PromotionRules
from .promotion_ledger import PromotionLedger
from .release_pipeline import ReleaseReport, release_evidence
Expand All @@ -47,6 +50,7 @@
"ReleaseSurfaceError",
"release_payload",
"release_text",
"verify_release",
"load_scores",
"load_rules",
"load_lineage",
Expand Down Expand Up @@ -270,6 +274,172 @@ def load_lineage(path: Optional[str]) -> LineageGraph:
raise ReleaseSurfaceError(f"lineage {path!r} is malformed: {error}") from None


def _receipt_release(receipt: Any) -> Dict[str, Any]:
"""The signed ``release`` object from a receipt, or from itself."""
if isinstance(receipt, Mapping):
payload = dict(receipt)
elif isinstance(receipt, str):
path = os.path.abspath(os.path.expanduser(receipt))
try:
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
except OSError as error:
raise ReleaseSurfaceError(
f"receipt {receipt!r} cannot be read: {error}") from None
except ValueError as error:
raise ReleaseSurfaceError(
f"receipt {receipt!r} is not valid JSON: {error}") from None
payload = _mapping(payload, "receipt")
else:
raise ReleaseSurfaceError("receipt must be a JSON object or a path")
release = payload.get("release") if "release" in payload else payload
if release is None:
raise ReleaseSurfaceError(
"receipt has no signed release; a deferred or rejected run cannot "
"be verified")
return _mapping(release, "receipt release")


def _receipt_manifest(value: Any) -> ArtifactManifest:
data = _mapping(value, "receipt release.envelope.manifest")
allowed = {
"artifact_id", "content_hash", "kind", "created_at", "parents",
"evidence", "metadata",
}
unknown = sorted(set(data) - allowed)
if unknown:
raise ReleaseSurfaceError(
"unknown manifest field(s): " + ", ".join(unknown))
parents = data.get("parents") or ()
if isinstance(parents, str):
parents = (parents,)
elif isinstance(parents, Sequence) and not isinstance(
parents, (str, bytes)):
parents = tuple(str(parent) for parent in parents)
else:
raise ReleaseSurfaceError(
"receipt release.envelope.manifest.parents must be a list or a string")
evidence = data.get("evidence") or {}
metadata = data.get("metadata") or {}
if not isinstance(evidence, Mapping):
raise ReleaseSurfaceError(
"receipt release.envelope.manifest.evidence must be a JSON object")
if not isinstance(metadata, Mapping):
raise ReleaseSurfaceError(
"receipt release.envelope.manifest.metadata must be a JSON object")
try:
created_at = float(data.get("created_at") or 0.0)
except (TypeError, ValueError):
raise ReleaseSurfaceError(
"receipt release.envelope.manifest.created_at must be a number"
) from None
try:
return ArtifactManifest(
artifact_id=_required_text(
data.get("artifact_id"),
"receipt release.envelope.manifest.artifact_id"),
content_hash=_required_text(
data.get("content_hash"),
"receipt release.envelope.manifest.content_hash"),
kind=_required_text(
data.get("kind"), "receipt release.envelope.manifest.kind"),
created_at=created_at,
parents=parents,
evidence=dict(evidence),
metadata=dict(metadata))
except (TypeError, ValueError) as error:
raise ReleaseSurfaceError(
f"receipt release.envelope.manifest is invalid: {error}") from None


def _receipt_envelope(release: Mapping[str, Any]) -> SignedEnvelope:
envelope = _mapping(
release.get("envelope"), "receipt release.envelope")
manifest = _receipt_manifest(
envelope.get("manifest") or release.get("manifest"))
signed_at = envelope.get("signed_at") or 0.0
try:
signed_at = float(signed_at)
except (TypeError, ValueError):
raise ReleaseSurfaceError(
"receipt release.envelope.signed_at must be a number") from None
return SignedEnvelope(
manifest=manifest,
key_id=_required_text(
envelope.get("key_id"), "receipt release.envelope.key_id"),
signature=_required_text(
envelope.get("signature"), "receipt release.envelope.signature"),
algorithm=str(envelope.get("algorithm") or "hmac-sha256"),
signed_at=signed_at)


def verify_release(receipt: Any, *, key_id: Optional[str] = None,
secret_env: Optional[str] = None,
secret_file: Optional[str] = None,
lineage: Optional[str] = None) -> Dict[str, Any]:
"""Verify a signed release receipt without trusting its JSON.

The receipt is what ``agentdescent release --json`` wrote (or its ``release``
sub-object). The signature is recomputed from the embedded manifest, so an
edited ``content_hash``, evidence or signature fails. ``key_id`` pins the
expected signer: a receipt signed by another key is invalid, not accepted.
``lineage`` additionally requires the artifact's node to carry the same
manifest hash.
"""
release = _receipt_release(receipt)
envelope = _receipt_envelope(release)
secret = resolve_secret(secret_env=secret_env, secret_file=secret_file)
expected_key = (None if key_id is None
else _required_text(key_id, "key_id"))
if expected_key is not None and expected_key != envelope.key_id:
# Do not consult a keyring under the receipt's own key here: the point
# of pinning is that a receipt naming another signer is refused.
check = None
valid = False
reason = (f"receipt is signed by {envelope.key_id!r}, expected "
f"{expected_key!r}")
else:
keyring = {expected_key or envelope.key_id: secret}
check = verify_manifest(envelope, keyring)
valid = check.valid
reason = check.reason
manifest = envelope.manifest
evidence = manifest.evidence
decision_hash = str(evidence.get("decision_hash") or "")
if valid and evidence.get("action") != "promote":
valid = False
reason = "the manifest's decision action is not 'promote'"
if valid and not (decision_hash and evidence.get("evidence_hash")):
valid = False
reason = "the manifest is missing decision_hash or evidence_hash"
lineage_checked = False
if valid and lineage is not None:
graph = load_lineage(lineage)
if manifest.artifact_id not in graph:
valid = False
reason = (f"lineage has no node for {manifest.artifact_id!r}")
else:
node = graph[manifest.artifact_id]
recorded = node.metadata.get("manifest_hash")
if recorded != manifest.manifest_hash():
valid = False
reason = "lineage manifest_hash does not match the receipt"
else:
lineage_checked = True
return {
"schema_version": RELEASE_SCHEMA_VERSION,
"ok": True,
"valid": valid,
"reason": reason,
"artifact_id": manifest.artifact_id,
"manifest_hash": manifest.manifest_hash(),
"decision_hash": decision_hash,
"key_id": ((check.key_id if check is not None else "")
or envelope.key_id),
"lineage_checked": lineage_checked,
}


def _atomic_write(path: str, text: str) -> None:
directory = os.path.dirname(os.path.abspath(path))
os.makedirs(directory, exist_ok=True)
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/736-release-verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Added
`agentdescent release verify` and the MCP `release_verify` tool: recompute the
HMAC over a signed receipt's embedded manifest, check that its decision is an
accepted `promote` with both hashes present, and optionally require the lineage
node to carry the same manifest hash. A well-formed but edited or wrongly keyed
receipt reports `valid: false` and exits 1, so CI can consume the exit code
without trusting the JSON file.
Loading
Loading