From 343042877cb809a91c99bfca5b03d91fe75a0232 Mon Sep 17 00:00:00 2001 From: Electric Wolfe Marshmallow Hypertext Date: Tue, 29 Sep 2026 15:40:23 -0500 Subject: [PATCH 1/5] chore: ignore local identity audit todo --- .gitignore | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index d792db2..1837d8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -venv/ +venv/ __pycache__/ .pytest_cache/ .tmp/ @@ -11,9 +11,9 @@ receipts/*.jsonl .agentmd/resolved-context.json node_modules/ dist/ -build/ +build/ -data/ +data/ .agentmd/last-resolved-path.txt .sticky/ @@ -21,3 +21,6 @@ data/ !examples/lead-artifacts/** !examples/lead-output/ !examples/lead-output/** + +# Local audit planning +TODO.local.md From b5c9af50bd8221436257c872d0f95feb404e8f85 Mon Sep 17 00:00:00 2001 From: Electric Wolfe Marshmallow Hypertext Date: Tue, 29 Sep 2026 15:40:47 -0500 Subject: [PATCH 2/5] chore: preserve gitignore formatting --- .gitignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 1837d8b..13e8cae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -venv/ +venv/ __pycache__/ .pytest_cache/ .tmp/ @@ -11,9 +11,9 @@ receipts/*.jsonl .agentmd/resolved-context.json node_modules/ dist/ -build/ +build/ -data/ +data/ .agentmd/last-resolved-path.txt .sticky/ From 1400df61285c2ad852ce12e27d061162d6da89b0 Mon Sep 17 00:00:00 2001 From: Electric Wolfe Marshmallow Hypertext Date: Tue, 29 Sep 2026 16:12:01 -0500 Subject: [PATCH 3/5] feat: add signed durable agent identity --- .gitignore | 1 + README.md | 21 + cli/agentmd.py | 729 ++++++++++++++++++++++++++ docs/cli_exit_codes.md | 14 + docs/identity_state.md | 97 ++++ docs/receipt_integrity.md | 17 +- docs/threat_model.md | 22 +- requirements-ci.lock | 154 ++++++ requirements-cli.txt | 1 + schemas/agent-identity.schema.json | 69 +++ schemas/identity-change.schema.json | 39 ++ schemas/identity-envelope.schema.json | 32 ++ tests/agentmd/test_agentmd_cli.py | 176 +++++++ 13 files changed, 1363 insertions(+), 9 deletions(-) create mode 100644 docs/identity_state.md create mode 100644 schemas/agent-identity.schema.json create mode 100644 schemas/identity-change.schema.json create mode 100644 schemas/identity-envelope.schema.json diff --git a/.gitignore b/.gitignore index 13e8cae..68c5b76 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ data/ # Local audit planning TODO.local.md +.agentmd/identity/ diff --git a/README.md b/README.md index e8b3249..383ddf4 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ It compiles scattered AI/tool runs into one verified current-state packet, with - `docs/determinism.md` - `docs/receipt_integrity.md` - `docs/threat_model.md` +- `docs/identity_state.md` - `SECURITY.md` ## Problem @@ -106,6 +107,24 @@ Scope intentionally not implemented yet: - multi-epoch benchmark loop - autonomous self-editing runtime +## Durable Agent Identity + +AgentMD can preserve a continuing agent identity separately from the current +state of work. Identity versions are immutable, hash-chained, and signed with +an external Ed25519 key that AgentMD never stores. + +- Commands: `agentmd identity init|verify|history|apply|rollback|export|import` +- Schemas: `agent-identity.v1`, `identity-change.v1`, and `identity-envelope.v1` +- Storage: `.agentmd/identity/records/*.json` +- Rollback appends a new signed version; it never deletes history +- Export/import uses a signed envelope and requires explicit fingerprint trust +- Identity changes are schema-validated, secret-scanned, and atomically written + +Identity answers "which continuing agent is this?" The AI Work Lead packet +continues to answer "what is currently true about the work?" The two states +remain separate. See `docs/identity_state.md` for the trust model and key +custody requirements. + ## Core Commands ```powershell @@ -115,6 +134,8 @@ Scope intentionally not implemented yet: .\agentmd.cmd run --adapter codex --task "review this repo for context drift" .\agentmd.cmd lead compile --task "compile ai work lead state" --artifact examples/lead-artifacts/run-codex.jsonl --artifact examples/lead-artifacts/run-claude.json --artifact examples/lead-artifacts/run-gemini.jsonl .\agentmd.cmd skill apply-edit --edit edits/validated-skill-edit.json +.\agentmd.cmd identity verify --trust-fingerprint "sha256:" +.\agentmd.cmd identity history ``` ## Quickstart diff --git a/cli/agentmd.py b/cli/agentmd.py index 91a0afc..8739a9a 100644 --- a/cli/agentmd.py +++ b/cli/agentmd.py @@ -1,23 +1,32 @@ #!/usr/bin/env python3 from __future__ import annotations +import base64 +import binascii import hashlib import json import re import subprocess import tempfile +import os +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any import typer import yaml +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey app = typer.Typer(add_completion=False, help="AgentMD context governance runtime CLI") lead_app = typer.Typer(add_completion=False, help="AI Work Lead primitives") skill_app = typer.Typer(add_completion=False, help="Skill optimization gates") +identity_app = typer.Typer(add_completion=False, help="Durable agent identity-state primitives") app.add_typer(lead_app, name="lead") app.add_typer(skill_app, name="skill") +app.add_typer(identity_app, name="identity") DEFAULT_CONFIG: dict[str, Any] = { "version": 1, @@ -65,6 +74,11 @@ LEAD_ARTIFACT_SCHEMA_FILE = "schemas/lead-artifact.schema.json" SKILL_EDIT_SCHEMA_FILE = "schemas/skill-edit.schema.json" SKILL_VALIDATION_SCHEMA_FILE = "schemas/skill-validation.schema.json" +IDENTITY_RECORD_SCHEMA_FILE = "schemas/agent-identity.schema.json" +IDENTITY_CHANGE_SCHEMA_FILE = "schemas/identity-change.schema.json" +IDENTITY_ENVELOPE_SCHEMA_FILE = "schemas/identity-envelope.schema.json" +IDENTITY_MAX_FILE_BYTES = 10_485_760 +IDENTITY_KEY_MAX_BYTES = 65_536 TRUST_WEIGHT_VERSION = "trust-weight.v1" TRUST_LOW_THRESHOLD = 0.5 LEAD_MAX_ARTIFACT_BYTES = 1_048_576 @@ -1019,6 +1033,721 @@ def _lead_secret_findings(value: Any, path: str = "$") -> list[str]: return findings +def _identity_canonical_bytes(payload: Any) -> bytes: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def _identity_hash(payload: Any) -> str: + return hashlib.sha256(_identity_canonical_bytes(payload)).hexdigest() + + +def _identity_state_dir(root: Path) -> Path: + return _require_path_within(root / ".agentmd" / "identity", root, "identity_state_outside_workspace") + + +def _identity_records_dir(root: Path) -> Path: + return _require_path_within(_identity_state_dir(root) / "records", root, "identity_state_outside_workspace") + + +@contextmanager +def _identity_write_lock(root: Path): + state_dir = _identity_state_dir(root) + state_dir.mkdir(parents=True, exist_ok=True) + lock_path = _require_path_within(state_dir / ".write.lock", root, "identity_lock_outside_workspace") + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError as e: + raise ValueError("identity_write_locked: another identity mutation is active") from e + try: + os.write(fd, str(os.getpid()).encode("ascii")) + yield + finally: + os.close(fd) + lock_path.unlink(missing_ok=True) + + +def _identity_validate_schema(root: Path, payload: dict[str, Any], schema_file: str, prefix: str) -> None: + schema = _load_json_schema(root, schema_file, f"{prefix}_schema_missing") + errors = _validate_json_payload(payload, schema, f"{prefix}_schema_invalid") + if errors: + raise ValueError("; ".join(errors)) + + +def _identity_private_key(path: Path) -> Ed25519PrivateKey: + key_path = path.expanduser() + if key_path.is_symlink(): + raise ValueError("identity_signing_key_symlink_not_allowed") + if not key_path.exists() or not key_path.is_file(): + raise ValueError(f"identity_signing_key_missing: {key_path}") + if key_path.stat().st_size > IDENTITY_KEY_MAX_BYTES: + raise ValueError("identity_signing_key_too_large") + key_bytes = key_path.read_bytes() + key: Any = None + try: + key = serialization.load_pem_private_key(key_bytes, password=None) + except (TypeError, ValueError): + try: + key = serialization.load_ssh_private_key(key_bytes, password=None) + except (TypeError, ValueError) as e: + raise ValueError("identity_signing_key_invalid_or_encrypted") from e + if not isinstance(key, Ed25519PrivateKey): + raise ValueError("identity_signing_key_must_be_ed25519") + return key + + +def _identity_signer(private_key: Ed25519PrivateKey) -> dict[str, str]: + public_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + fingerprint_hex = hashlib.sha256(public_bytes).hexdigest() + return { + "algorithm": "ed25519", + "fingerprint": f"sha256:{fingerprint_hex}", + "public_key": base64.b64encode(public_bytes).decode("ascii"), + } + + +def _identity_agent_id(signer: dict[str, str]) -> str: + return f"agentmd:{signer['fingerprint'].split(':', 1)[1]}" + + +def _identity_sign_hash(private_key: Ed25519PrivateKey, digest_hex: str) -> str: + return base64.b64encode(private_key.sign(bytes.fromhex(digest_hex))).decode("ascii") + + +def _identity_public_key(signer: dict[str, Any]) -> Ed25519PublicKey: + try: + public_bytes = base64.b64decode(str(signer.get("public_key") or ""), validate=True) + except (ValueError, binascii.Error) as e: + raise ValueError("identity_public_key_invalid") from e + if len(public_bytes) != 32: + raise ValueError("identity_public_key_invalid") + fingerprint = f"sha256:{hashlib.sha256(public_bytes).hexdigest()}" + if fingerprint != signer.get("fingerprint"): + raise ValueError("identity_signer_fingerprint_mismatch") + try: + return Ed25519PublicKey.from_public_bytes(public_bytes) + except ValueError as e: + raise ValueError("identity_public_key_invalid") from e + + +def _identity_verify_hash_signature(signer: dict[str, Any], digest_hex: str, signature: Any) -> None: + try: + signature_bytes = base64.b64decode(str(signature or ""), validate=True) + except (ValueError, binascii.Error) as e: + raise ValueError("identity_signature_invalid_encoding") from e + try: + _identity_public_key(signer).verify(signature_bytes, bytes.fromhex(digest_hex)) + except (InvalidSignature, ValueError) as e: + raise ValueError("identity_signature_verification_failed") from e + + +def _identity_record_material(record: dict[str, Any]) -> dict[str, Any]: + material = dict(record) + material.pop("receipt_hash", None) + material.pop("signature", None) + return material + + +def _identity_verify_record(root: Path, record: dict[str, Any]) -> None: + _identity_validate_schema(root, record, IDENTITY_RECORD_SCHEMA_FILE, "identity_record") + if _lead_secret_findings(record.get("state")): + raise ValueError("identity_record_secret_detected") + if _identity_hash(record.get("state")) != record.get("state_hash"): + raise ValueError("identity_state_hash_mismatch") + receipt_hash = _identity_hash(_identity_record_material(record)) + if receipt_hash != record.get("receipt_hash"): + raise ValueError("identity_receipt_hash_mismatch") + signer = record.get("signer") + if not isinstance(signer, dict): + raise ValueError("identity_signer_invalid") + if _identity_agent_id(signer) != record.get("agent_id"): + raise ValueError("identity_agent_id_signer_mismatch") + _identity_verify_hash_signature(signer, receipt_hash, record.get("signature")) + + +def _identity_verify_records( + root: Path, + records: list[dict[str, Any]], + expected_fingerprint: str | None = None, +) -> list[dict[str, Any]]: + if not records: + raise ValueError("identity_not_initialized") + first_agent_id: str | None = None + first_fingerprint: str | None = None + previous: dict[str, Any] | None = None + for expected_version, record in enumerate(records, start=1): + _identity_verify_record(root, record) + signer = record["signer"] + if record["version"] != expected_version: + raise ValueError("identity_version_sequence_invalid") + if first_agent_id is None: + first_agent_id = str(record["agent_id"]) + first_fingerprint = str(signer["fingerprint"]) + if record["event"] != "init": + raise ValueError("identity_first_record_not_init") + if record["previous_state_hash"] is not None or record["previous_receipt_hash"] is not None: + raise ValueError("identity_first_record_has_previous_hash") + else: + assert previous is not None + if record["agent_id"] != first_agent_id or signer["fingerprint"] != first_fingerprint: + raise ValueError("identity_signer_changed") + if record["event"] == "init": + raise ValueError("identity_duplicate_init") + if record["previous_state_hash"] != previous["state_hash"]: + raise ValueError("identity_previous_state_hash_mismatch") + if record["previous_receipt_hash"] != previous["receipt_hash"]: + raise ValueError("identity_previous_receipt_hash_mismatch") + if record["event"] == "rollback" and record["target_version"] is None: + raise ValueError("identity_rollback_target_missing") + if record["event"] != "rollback" and record["target_version"] is not None: + raise ValueError("identity_target_version_unexpected") + previous = record + if expected_fingerprint is not None and first_fingerprint != expected_fingerprint: + raise ValueError("identity_signer_fingerprint_mismatch") + return records + + +def _identity_load_record_file(root: Path, path: Path) -> dict[str, Any]: + if path.is_symlink(): + raise ValueError("identity_record_symlink_not_allowed") + path = _require_path_within(path, root, "identity_record_outside_workspace") + if path.stat().st_size > IDENTITY_MAX_FILE_BYTES: + raise ValueError(f"identity_record_too_large: {_display_path(root, path)}") + try: + payload = _strict_json_loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError) as e: + raise ValueError(f"identity_record_invalid_json: {_display_path(root, path)}") from e + if not isinstance(payload, dict): + raise ValueError(f"identity_record_invalid_shape: {_display_path(root, path)}") + return payload + + +def _identity_load_records(root: Path, expected_fingerprint: str | None = None) -> list[dict[str, Any]]: + records_dir = _identity_records_dir(root) + if not records_dir.exists(): + return [] + paths = sorted(records_dir.glob("*.json")) + records = [_identity_load_record_file(root, path) for path in paths] + if records: + _identity_verify_records(root, records, expected_fingerprint=expected_fingerprint) + return records + + +def _identity_write_record(root: Path, record: dict[str, Any]) -> Path: + records_dir = _identity_records_dir(root) + records_dir.mkdir(parents=True, exist_ok=True) + path = _require_path_within( + records_dir / f"{int(record['version']):08d}.json", + root, + "identity_record_outside_workspace", + ) + if path.exists(): + raise ValueError(f"identity_version_already_exists: {record['version']}") + _atomic_write_text(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + return path + + +def _identity_build_record( + root: Path, + private_key: Ed25519PrivateKey, + state: dict[str, Any], + version: int, + event: str, + previous: dict[str, Any] | None, + summary: str, + change_id: str | None = None, + target_version: int | None = None, + requested_by: str | None = None, + approved_by: str | None = None, +) -> dict[str, Any]: + signer = _identity_signer(private_key) + record: dict[str, Any] = { + "schema_version": "agent-identity.v1", + "agent_id": _identity_agent_id(signer), + "version": version, + "event": event, + "timestamp": _utc_now_iso(), + "state": state, + "state_hash": _identity_hash(state), + "previous_state_hash": previous.get("state_hash") if previous else None, + "previous_receipt_hash": previous.get("receipt_hash") if previous else None, + "change_id": change_id, + "summary": summary, + "target_version": target_version, + "requested_by": requested_by, + "approved_by": approved_by, + "signer": signer, + } + record["receipt_hash"] = _identity_hash(record) + record["signature"] = _identity_sign_hash(private_key, record["receipt_hash"]) + _identity_verify_record(root, record) + return record + + +def _identity_read_workspace_json(root: Path, path: Path, prefix: str) -> dict[str, Any]: + if path.is_symlink(): + raise ValueError(f"{prefix}_symlink_not_allowed") + path = _require_path_within(path, root, f"{prefix}_outside_workspace") + if not path.exists() or not path.is_file(): + raise ValueError(f"{prefix}_missing: {_display_path(root, path)}") + if path.stat().st_size > IDENTITY_MAX_FILE_BYTES: + raise ValueError(f"{prefix}_too_large") + try: + payload = _strict_json_loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError) as e: + raise ValueError(f"{prefix}_invalid_json") from e + if not isinstance(payload, dict): + raise ValueError(f"{prefix}_invalid_shape") + if _lead_json_depth(payload) > LEAD_MAX_JSON_DEPTH: + raise ValueError(f"{prefix}_too_deep") + return payload + + +def _identity_load_change(root: Path, path: Path) -> dict[str, Any]: + change = _identity_read_workspace_json(root, path, "identity_change") + _identity_validate_schema(root, change, IDENTITY_CHANGE_SCHEMA_FILE, "identity_change") + findings = _lead_secret_findings(change) + if findings: + raise ValueError("identity_change_secret_detected: " + ", ".join(sorted(findings))) + return change + + +def _identity_apply_operations(state: dict[str, Any], operations: list[dict[str, Any]]) -> dict[str, Any]: + updated = json.loads(json.dumps(state)) + seen_paths: set[str] = set() + for operation in operations: + op = str(operation.get("op")) + path = str(operation.get("path")) + if path in seen_paths: + raise ValueError(f"identity_change_duplicate_path: {path}") + seen_paths.add(path) + has_value = "value" in operation + if op in {"add", "replace"} and not has_value: + raise ValueError(f"identity_change_value_required: {path}") + if op == "remove" and has_value: + raise ValueError(f"identity_change_remove_value_forbidden: {path}") + + if path in {"/name", "/purpose"}: + if op != "replace": + raise ValueError(f"identity_change_operation_not_allowed: {op} {path}") + value = operation["value"] + if not isinstance(value, str) or (path == "/name" and not value.strip()): + raise ValueError(f"identity_change_value_invalid: {path}") + updated[path[1:]] = value.strip() if path == "/name" else value + continue + + if path in {"/operating_principles", "/boundaries"}: + if op != "replace": + raise ValueError(f"identity_change_operation_not_allowed: {op} {path}") + value = operation["value"] + if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): + raise ValueError(f"identity_change_value_invalid: {path}") + updated[path[1:]] = value + continue + + parts = path.strip("/").split("/", 1) + if len(parts) != 2 or parts[0] not in {"working_style", "user_preferences"}: + raise ValueError(f"identity_change_path_invalid: {path}") + collection = updated[parts[0]] + key = parts[1] + exists = key in collection + if op == "add": + if exists: + raise ValueError(f"identity_change_add_target_exists: {path}") + value = operation["value"] + if not isinstance(value, str): + raise ValueError(f"identity_change_value_invalid: {path}") + collection[key] = value + elif op == "replace": + if not exists: + raise ValueError(f"identity_change_replace_target_missing: {path}") + value = operation["value"] + if not isinstance(value, str): + raise ValueError(f"identity_change_value_invalid: {path}") + collection[key] = value + elif op == "remove": + if not exists: + raise ValueError(f"identity_change_remove_target_missing: {path}") + del collection[key] + else: + raise ValueError(f"identity_change_operation_invalid: {op}") + + if _lead_secret_findings(updated): + raise ValueError("identity_state_secret_detected") + return updated + + +def _identity_key_matches_record(private_key: Ed25519PrivateKey, record: dict[str, Any]) -> None: + signer = _identity_signer(private_key) + if signer["fingerprint"] != record["signer"]["fingerprint"]: + raise ValueError("identity_signing_key_mismatch") + + +def _identity_command_error(error: ValueError, json_output: bool) -> None: + if json_output: + typer.echo(json.dumps({"ok": False, "error": str(error)})) + else: + typer.echo(str(error)) + raise typer.Exit(code=1) + + +@identity_app.command("init") +def identity_init( + name: str = typer.Option(..., "--name", help="Human-readable agent name"), + signing_key: Path = typer.Option(..., "--signing-key", help="External Ed25519 private key path"), + purpose: str = typer.Option("", "--purpose", help="Stable identity purpose"), + root: Path = typer.Option(Path("."), "--root", help="Workspace root"), + json_output: bool = typer.Option(False, "--json", help="Print JSON output"), +) -> None: + root = root.resolve() + try: + private_key = _identity_private_key(signing_key) + with _identity_write_lock(root): + if _identity_load_records(root): + raise ValueError("identity_already_initialized") + state = { + "name": name.strip(), + "purpose": purpose, + "working_style": {}, + "user_preferences": {}, + "operating_principles": [], + "boundaries": [], + } + if _lead_secret_findings(state): + raise ValueError("identity_state_secret_detected") + record = _identity_build_record( + root, + private_key, + state, + version=1, + event="init", + previous=None, + summary="Initialize durable agent identity.", + ) + record_path = _identity_write_record(root, record) + except ValueError as e: + _identity_command_error(e, json_output) + return + result = { + "ok": True, + "agent_id": record["agent_id"], + "fingerprint": record["signer"]["fingerprint"], + "version": record["version"], + "receipt_hash": record["receipt_hash"], + "record": _display_path(root, record_path), + } + if json_output: + typer.echo(json.dumps(result, indent=2)) + else: + for key, value in result.items(): + if key != "ok": + typer.echo(f"{key}: {value}") + + +@identity_app.command("verify") +def identity_verify( + root: Path = typer.Option(Path("."), "--root", help="Workspace root"), + trust_fingerprint: str | None = typer.Option(None, "--trust-fingerprint", help="Expected sha256 public-key fingerprint"), + json_output: bool = typer.Option(False, "--json", help="Print JSON output"), +) -> None: + root = root.resolve() + try: + records = _identity_load_records(root, expected_fingerprint=trust_fingerprint) + if not records: + raise ValueError("identity_not_initialized") + except ValueError as e: + _identity_command_error(e, json_output) + return + latest = records[-1] + result = { + "ok": True, + "agent_id": latest["agent_id"], + "fingerprint": latest["signer"]["fingerprint"], + "version": latest["version"], + "state_hash": latest["state_hash"], + "state": latest["state"], + "receipt_hash": latest["receipt_hash"], + "assurance": "trusted" if trust_fingerprint else "self_consistent", + } + if json_output: + typer.echo(json.dumps(result, indent=2)) + else: + for key, value in result.items(): + typer.echo(f"{key}: {value}") + + +@identity_app.command("history") +def identity_history( + root: Path = typer.Option(Path("."), "--root", help="Workspace root"), + json_output: bool = typer.Option(False, "--json", help="Print JSON output"), +) -> None: + root = root.resolve() + try: + records = _identity_load_records(root) + if not records: + raise ValueError("identity_not_initialized") + except ValueError as e: + _identity_command_error(e, json_output) + return + history = [ + { + "version": record["version"], + "event": record["event"], + "timestamp": record["timestamp"], + "summary": record["summary"], + "change_id": record["change_id"], + "target_version": record["target_version"], + "state_hash": record["state_hash"], + "receipt_hash": record["receipt_hash"], + "previous_receipt_hash": record["previous_receipt_hash"], + "requested_by": record["requested_by"], + "approved_by": record["approved_by"], + } + for record in records + ] + if json_output: + typer.echo(json.dumps(history, indent=2)) + else: + for item in history: + typer.echo( + f"version={item['version']} event={item['event']} " + f"receipt_hash={item['receipt_hash']} summary={item['summary']}" + ) + + +@identity_app.command("apply") +def identity_apply( + change: Path = typer.Option(..., "--change", help="Identity change proposal JSON within the workspace"), + signing_key: Path = typer.Option(..., "--signing-key", help="External Ed25519 private key path"), + root: Path = typer.Option(Path("."), "--root", help="Workspace root"), + json_output: bool = typer.Option(False, "--json", help="Print JSON output"), +) -> None: + root = root.resolve() + try: + private_key = _identity_private_key(signing_key) + with _identity_write_lock(root): + records = _identity_load_records(root) + if not records: + raise ValueError("identity_not_initialized") + latest = records[-1] + _identity_key_matches_record(private_key, latest) + proposal = _identity_load_change(root, change) + if proposal["agent_id"] != latest["agent_id"]: + raise ValueError("identity_change_agent_id_mismatch") + if proposal["from_version"] != latest["version"]: + raise ValueError("identity_change_version_mismatch") + state = _identity_apply_operations(latest["state"], proposal["operations"]) + record = _identity_build_record( + root, + private_key, + state, + version=int(latest["version"]) + 1, + event="change", + previous=latest, + summary=proposal["summary"], + change_id=proposal["change_id"], + requested_by=proposal["requested_by"], + approved_by=proposal["approved_by"], + ) + record_path = _identity_write_record(root, record) + except ValueError as e: + _identity_command_error(e, json_output) + return + result = { + "ok": True, + "agent_id": record["agent_id"], + "version": record["version"], + "state_hash": record["state_hash"], + "receipt_hash": record["receipt_hash"], + "record": _display_path(root, record_path), + } + if json_output: + typer.echo(json.dumps(result, indent=2)) + else: + for key, value in result.items(): + if key != "ok": + typer.echo(f"{key}: {value}") + + +@identity_app.command("rollback") +def identity_rollback( + version: int = typer.Option(..., "--version", min=1, help="Historical identity version to restore"), + signing_key: Path = typer.Option(..., "--signing-key", help="External Ed25519 private key path"), + root: Path = typer.Option(Path("."), "--root", help="Workspace root"), + requested_by: str | None = typer.Option(None, "--requested-by"), + approved_by: str | None = typer.Option(None, "--approved-by"), + json_output: bool = typer.Option(False, "--json", help="Print JSON output"), +) -> None: + root = root.resolve() + try: + private_key = _identity_private_key(signing_key) + with _identity_write_lock(root): + records = _identity_load_records(root) + if not records: + raise ValueError("identity_not_initialized") + latest = records[-1] + _identity_key_matches_record(private_key, latest) + if version >= int(latest["version"]): + raise ValueError("identity_rollback_target_must_be_historical") + target = next((record for record in records if record["version"] == version), None) + if target is None: + raise ValueError("identity_rollback_target_missing") + record = _identity_build_record( + root, + private_key, + json.loads(json.dumps(target["state"])), + version=int(latest["version"]) + 1, + event="rollback", + previous=latest, + summary=f"Rollback identity state to version {version}.", + target_version=version, + requested_by=requested_by, + approved_by=approved_by, + ) + record_path = _identity_write_record(root, record) + except ValueError as e: + _identity_command_error(e, json_output) + return + result = { + "ok": True, + "agent_id": record["agent_id"], + "version": record["version"], + "target_version": version, + "state_hash": record["state_hash"], + "receipt_hash": record["receipt_hash"], + "record": _display_path(root, record_path), + } + if json_output: + typer.echo(json.dumps(result, indent=2)) + else: + for key, value in result.items(): + if key != "ok": + typer.echo(f"{key}: {value}") + + +def _identity_envelope_material(envelope: dict[str, Any]) -> dict[str, Any]: + material = dict(envelope) + material.pop("envelope_hash", None) + material.pop("signature", None) + return material + + +@identity_app.command("export") +def identity_export( + output: Path = typer.Option(..., "--output", help="Signed identity export path within the workspace"), + signing_key: Path = typer.Option(..., "--signing-key", help="External Ed25519 private key path"), + root: Path = typer.Option(Path("."), "--root", help="Workspace root"), + json_output: bool = typer.Option(False, "--json", help="Print JSON output"), +) -> None: + root = root.resolve() + try: + records = _identity_load_records(root) + if not records: + raise ValueError("identity_not_initialized") + latest = records[-1] + private_key = _identity_private_key(signing_key) + _identity_key_matches_record(private_key, latest) + output_path = _require_path_within(output, root, "identity_export_outside_workspace") + envelope: dict[str, Any] = { + "schema_version": "identity-envelope.v1", + "agent_id": latest["agent_id"], + "exported_at": _utc_now_iso(), + "record_count": len(records), + "latest_version": latest["version"], + "latest_receipt_hash": latest["receipt_hash"], + "signer": latest["signer"], + "records": records, + } + envelope["envelope_hash"] = _identity_hash(envelope) + envelope["signature"] = _identity_sign_hash(private_key, envelope["envelope_hash"]) + _identity_validate_schema(root, envelope, IDENTITY_ENVELOPE_SCHEMA_FILE, "identity_envelope") + _atomic_write_text(output_path, json.dumps(envelope, indent=2, sort_keys=True) + "\n") + except ValueError as e: + _identity_command_error(e, json_output) + return + result = { + "ok": True, + "agent_id": latest["agent_id"], + "version": latest["version"], + "fingerprint": latest["signer"]["fingerprint"], + "envelope_hash": envelope["envelope_hash"], + "output": _display_path(root, output_path), + } + if json_output: + typer.echo(json.dumps(result, indent=2)) + else: + for key, value in result.items(): + if key != "ok": + typer.echo(f"{key}: {value}") + + +@identity_app.command("import") +def identity_import( + input_path: Path = typer.Option(..., "--input", help="Signed identity export within the workspace"), + trust_fingerprint: str = typer.Option(..., "--trust-fingerprint", help="Expected sha256 public-key fingerprint"), + root: Path = typer.Option(Path("."), "--root", help="Workspace root"), + json_output: bool = typer.Option(False, "--json", help="Print JSON output"), +) -> None: + root = root.resolve() + try: + envelope = _identity_read_workspace_json(root, input_path, "identity_import") + _identity_validate_schema(root, envelope, IDENTITY_ENVELOPE_SCHEMA_FILE, "identity_envelope") + envelope_hash = _identity_hash(_identity_envelope_material(envelope)) + if envelope_hash != envelope.get("envelope_hash"): + raise ValueError("identity_envelope_hash_mismatch") + signer = envelope.get("signer") + if not isinstance(signer, dict): + raise ValueError("identity_signer_invalid") + if signer.get("fingerprint") != trust_fingerprint: + raise ValueError("identity_signer_fingerprint_mismatch") + _identity_verify_hash_signature(signer, envelope_hash, envelope.get("signature")) + raw_records = envelope.get("records") + if not isinstance(raw_records, list) or not all(isinstance(item, dict) for item in raw_records): + raise ValueError("identity_envelope_records_invalid") + records = _identity_verify_records(root, list(raw_records), expected_fingerprint=trust_fingerprint) + latest = records[-1] + if envelope["agent_id"] != latest["agent_id"]: + raise ValueError("identity_envelope_agent_id_mismatch") + if envelope["record_count"] != len(records): + raise ValueError("identity_envelope_record_count_mismatch") + if envelope["latest_version"] != latest["version"]: + raise ValueError("identity_envelope_latest_version_mismatch") + if envelope["latest_receipt_hash"] != latest["receipt_hash"]: + raise ValueError("identity_envelope_latest_receipt_mismatch") + if envelope["signer"] != latest["signer"]: + raise ValueError("identity_envelope_signer_mismatch") + + imported_records = 0 + with _identity_write_lock(root): + existing = _identity_load_records(root, expected_fingerprint=trust_fingerprint) + if len(existing) > len(records): + raise ValueError("identity_import_would_rewind_history") + for index, current in enumerate(existing): + if current["receipt_hash"] != records[index]["receipt_hash"]: + raise ValueError("identity_import_history_conflict") + for record in records[len(existing):]: + _identity_write_record(root, record) + imported_records += 1 + except ValueError as e: + _identity_command_error(e, json_output) + return + result = { + "ok": True, + "agent_id": latest["agent_id"], + "fingerprint": latest["signer"]["fingerprint"], + "version": latest["version"], + "receipt_hash": latest["receipt_hash"], + "imported_records": imported_records, + } + if json_output: + typer.echo(json.dumps(result, indent=2)) + else: + for key, value in result.items(): + if key != "ok": + typer.echo(f"{key}: {value}") + def _lead_load_artifacts_from_file(root: Path, path: Path) -> tuple[list[tuple[dict[str, Any], str]], list[str]]: records: list[tuple[dict[str, Any], str]] = [] warnings: list[str] = [] diff --git a/docs/cli_exit_codes.md b/docs/cli_exit_codes.md index 426e7af..2222467 100644 --- a/docs/cli_exit_codes.md +++ b/docs/cli_exit_codes.md @@ -56,3 +56,17 @@ Notes: Notes: - `skill apply-edit` uses error code `1` for domain failures and disambiguates by message code. + +## identity + +Applies to `identity init`, `verify`, `history`, `apply`, `rollback`, `export`, +and `import`. + +- Success: `0`. +- Validation, schema, signature, trust, corruption, conflict, or missing-file failure: `1`. +- Usage error: `2` for missing or invalid CLI arguments. +- Internal error: `1`. + +Identity domain failures use explicit messages such as +`identity_signature_verification_failed`, +`identity_signer_fingerprint_mismatch`, and `identity_import_history_conflict`. diff --git a/docs/identity_state.md b/docs/identity_state.md new file mode 100644 index 0000000..248877b --- /dev/null +++ b/docs/identity_state.md @@ -0,0 +1,97 @@ +# Durable Agent Identity + +AgentMD identity state answers one question: **which continuing agent is this?** +It is deliberately separate from the AI Work Lead packet, which answers **what +is currently true about the work?** + +## Storage and integrity + +Identity is file-native. Each version is an immutable JSON record under: + +```text +.agentmd/identity/records/00000001.json +``` + +Every record contains the complete identity state, its SHA-256 hash, the +previous state and receipt hashes, change provenance, the signer public key, +and an Ed25519 signature. Rollback appends a new signed version containing a +historical state; it does not delete or rewrite history. + +Runtime identity records are ignored by Git by default. + +## Key custody + +AgentMD accepts an external Ed25519 private key through `--signing-key`. +AgentMD does not generate, copy, export, or store that private key. The operator +is responsible for keeping it outside the workspace and inaccessible to +untrusted agents and tools. + +The stable agent ID is derived from the SHA-256 fingerprint of the corresponding +public key: + +```text +agentmd:<64-character-public-key-hash> +``` + +If an LLM or tool can access the private key, it can authorize identity changes. +Therefore model processes must not receive the key or permission to invoke +signing on the operator's behalf. + +## Validation + +Before AgentMD stores an identity change, it verifies: + +- strict JSON and `identity-change.v1` schema conformance +- workspace containment and symlink rejection for change/import inputs +- bounded add, replace, or remove operations +- expected agent ID and source version +- high-confidence secret patterns +- the external signing key matches the established identity +- the complete existing signature and hash chain + +Writes use a workspace lock and atomic file replacement. Failed validation does +not append an identity version. + +## Verification assurance + +`identity verify` without a fingerprint proves that the stored chain is +self-consistent. It does not rule out wholesale replacement by a different +self-consistent identity. + +`identity verify --trust-fingerprint ` additionally proves that +the chain is signed by the public key the operator selected out of band. + +## Commands + +```powershell +.\agentmd.cmd identity init --name "Continuing Agent" --purpose "Preserve continuity" --signing-key C:\secure\agentmd-ed25519.pem +.\agentmd.cmd identity verify --trust-fingerprint "sha256:" +.\agentmd.cmd identity history +.\agentmd.cmd identity apply --change identity-change.json --signing-key C:\secure\agentmd-ed25519.pem +.\agentmd.cmd identity rollback --version 1 --signing-key C:\secure\agentmd-ed25519.pem +.\agentmd.cmd identity export --output identity-export.json --signing-key C:\secure\agentmd-ed25519.pem +.\agentmd.cmd identity import --input identity-export.json --trust-fingerprint "sha256:" +``` + +Import accepts an empty destination or an exact prefix of the exported history. +It rejects a conflicting history and refuses to rewind a destination with newer +records. + +## Schemas + +- `schemas/agent-identity.schema.json`: signed identity version +- `schemas/identity-change.schema.json`: bounded change proposal +- `schemas/identity-envelope.schema.json`: signed export envelope + +## Explicit exclusions + +This implementation does not include: + +- Anchor's SQLite database or code +- private-key storage +- unsigned exports or validation bypass flags +- Ollama/compiler loops +- tool/security policy systems +- automatic identity changes from model output +- signatures for existing Lead, context, or skill receipts +- external notarization, transparency logs, or hardware attestation diff --git a/docs/receipt_integrity.md b/docs/receipt_integrity.md index 3a0e2c4..ea85995 100644 --- a/docs/receipt_integrity.md +++ b/docs/receipt_integrity.md @@ -8,6 +8,8 @@ This document describes receipt integrity properties in AgentMD today. - Lead compile receipts: `.sticky/receipts/*.jsonl` - Skill gate receipts: `.sticky/skill-receipts/*.jsonl` - Rejected skill edit records: `.sticky/rejected-skill-edits/*.jsonl` +- Identity version records: `.agentmd/identity/records/*.json` (signed and + hash-chained) ## What receipt hashes cover @@ -46,13 +48,18 @@ Limitations: - no external notarization - no transparency log - no hardware-backed attestation -- no signature chain across environments -- anyone with write access can still replace a receipt chain and recompute unsigned hashes +- existing Lead, context, and skill receipts have no signature chain across environments +- anyone with write access can replace an unsigned receipt chain and recompute its hashes -## What is not yet cryptographically signed +## Cryptographic signing boundary -- receipts are not signed with asymmetric keys +- Identity version records and identity export envelopes are signed with + Ed25519. Their public-key fingerprint can be pinned during verification and + is required during import. +- AgentMD never stores the corresponding private key. +- Lead, context, and skill receipts are not signed with asymmetric keys. - releases are not tied to receipt signatures - receipt provenance is not anchored to an external immutable ledger -For stable release hardening, signed receipts and external attestation can be added as later phases. +Identity signing does not retroactively authenticate AI/tool artifact sources, +Lead receipts, skill evaluators, or releases. diff --git a/docs/threat_model.md b/docs/threat_model.md index 6129b56..c6f95d0 100644 --- a/docs/threat_model.md +++ b/docs/threat_model.md @@ -5,6 +5,7 @@ - repository instructions, skills, memory, policies, and evals - Lead Artifact inputs and compiled current-state packets - execution and skill-edit receipts +- signed agent identity records and export envelopes - Git and CI provenance - credentials present in developer or CI environments @@ -26,8 +27,16 @@ selected workspace. Skill mutations are further restricted to ### Receipt boundary Lead receipts bind source artifact hashes and chain to the previous valid -receipt. Modified receipts are excluded from trust history. These controls are -tamper-evident, not tamper-proof: receipts are not asymmetrically signed. +receipt. Modified receipts are excluded from trust history. Existing Lead, +context, and skill receipts are hash-based and are not asymmetrically signed. + +### Identity boundary + +Identity records and export envelopes are Ed25519-signed and hash-chained. +AgentMD stores the public key but never stores or generates the private key. +Cross-machine import requires an explicitly trusted public-key fingerprint. +Self-consistent verification proves internal chain integrity; fingerprint-pinned +verification additionally proves continuity with the key the operator trusts. ### CI boundary @@ -48,6 +57,9 @@ timeouts, vulnerability auditing, and repository indicator scanning. | Trust-score replay | source hash and run ID observations are deduplicated | | Receipt-history modification | receipt hash verification and previous-hash chaining | | Self-asserted skill score | accepted edits require a bound validation receipt | +| Identity record tampering | Ed25519 signatures, state hashes, and receipt-hash chaining | +| Identity substitution during import | required operator-pinned signer fingerprint | +| Identity history rewind | append-only versions and import prefix validation | | CI dependency substitution | exact Action SHAs and hash-locked Python packages | | Executable model/data formats | unsupported; AgentMD accepts JSON/JSONL artifacts only | @@ -84,10 +96,12 @@ Primary sources: ## Explicit non-guarantees - No cryptographic source identity for AI/tool artifacts yet. -- No asymmetric receipt or evaluator signatures yet. +- Lead, context, and skill receipts are not asymmetrically signed. +- Identity signatures prove control of the trusted private key, not that a + human personally authored or approved the identity content. - No sandboxed adapter execution because adapters are not executed in v0.x. - No guarantee against a malicious actor who controls both the workspace and - the signing or CI identity planned for future releases. + the external identity signing key. - No production SaaS security claim. - No runtime egress sandbox; AgentMD v0.x does not execute adapters, and CI network policy remains a repository/runner control. diff --git a/requirements-ci.lock b/requirements-ci.lock index 29f571a..0b0ace1 100644 --- a/requirements-ci.lock +++ b/requirements-ci.lock @@ -28,6 +28,108 @@ certifi==2026.7.22 \ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography charset-normalizer==3.5.1 \ --hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \ --hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \ @@ -210,6 +312,54 @@ colorama==0.4.6 \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 # via pytest +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via -r requirements-cli.txt cyclonedx-python-lib==11.12.0 \ --hash=sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54 \ --hash=sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf @@ -375,6 +525,10 @@ py-serializable==2.1.0 \ --hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \ --hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304 # via cyclonedx-python-lib +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi pygments==2.21.0 \ --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c diff --git a/requirements-cli.txt b/requirements-cli.txt index c7e9dea..54012b9 100644 --- a/requirements-cli.txt +++ b/requirements-cli.txt @@ -2,3 +2,4 @@ typer==0.25.1 requests==2.33.0 jsonschema==4.25.1 pyyaml==6.0.3 +cryptography==50.0.1 diff --git a/schemas/agent-identity.schema.json b/schemas/agent-identity.schema.json new file mode 100644 index 0000000..f4ebda2 --- /dev/null +++ b/schemas/agent-identity.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentmd.dev/schemas/agent-identity.schema.json", + "title": "AgentMD Signed Identity Record", + "type": "object", + "required": [ + "schema_version", "agent_id", "version", "event", "timestamp", + "state", "state_hash", "previous_state_hash", "previous_receipt_hash", + "change_id", "summary", "target_version", "requested_by", "approved_by", + "signer", "receipt_hash", "signature" + ], + "properties": { + "schema_version": {"const": "agent-identity.v1"}, + "agent_id": {"type": "string", "pattern": "^agentmd:[a-f0-9]{64}$"}, + "version": {"type": "integer", "minimum": 1}, + "event": {"type": "string", "enum": ["init", "change", "rollback"]}, + "timestamp": {"type": "string", "format": "date-time", "maxLength": 64}, + "state": { + "type": "object", + "required": ["name", "purpose", "working_style", "user_preferences", "operating_principles", "boundaries"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 256}, + "purpose": {"type": "string", "maxLength": 16384}, + "working_style": { + "type": "object", + "maxProperties": 128, + "propertyNames": {"pattern": "^[A-Za-z0-9_.-]{1,128}$"}, + "additionalProperties": {"type": "string", "maxLength": 8192} + }, + "user_preferences": { + "type": "object", + "maxProperties": 128, + "propertyNames": {"pattern": "^[A-Za-z0-9_.-]{1,128}$"}, + "additionalProperties": {"type": "string", "maxLength": 8192} + }, + "operating_principles": { + "type": "array", "maxItems": 128, + "items": {"type": "string", "minLength": 1, "maxLength": 8192} + }, + "boundaries": { + "type": "array", "maxItems": 128, + "items": {"type": "string", "minLength": 1, "maxLength": 8192} + } + }, + "additionalProperties": false + }, + "state_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "previous_state_hash": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}, + "previous_receipt_hash": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}, + "change_id": {"type": ["string", "null"], "minLength": 1, "maxLength": 256}, + "summary": {"type": "string", "maxLength": 4096}, + "target_version": {"type": ["integer", "null"], "minimum": 1}, + "requested_by": {"type": ["string", "null"], "maxLength": 256}, + "approved_by": {"type": ["string", "null"], "maxLength": 256}, + "signer": { + "type": "object", + "required": ["algorithm", "fingerprint", "public_key"], + "properties": { + "algorithm": {"const": "ed25519"}, + "fingerprint": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "public_key": {"type": "string", "minLength": 43, "maxLength": 64} + }, + "additionalProperties": false + }, + "receipt_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "signature": {"type": "string", "minLength": 86, "maxLength": 128} + }, + "additionalProperties": false +} diff --git a/schemas/identity-change.schema.json b/schemas/identity-change.schema.json new file mode 100644 index 0000000..ff45b18 --- /dev/null +++ b/schemas/identity-change.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentmd.dev/schemas/identity-change.schema.json", + "title": "AgentMD Identity Change Proposal", + "type": "object", + "required": ["schema_version", "change_id", "agent_id", "from_version", "summary", "operations", "requested_by", "approved_by", "timestamp"], + "properties": { + "schema_version": {"const": "identity-change.v1"}, + "change_id": {"type": "string", "minLength": 1, "maxLength": 256}, + "agent_id": {"type": "string", "pattern": "^agentmd:[a-f0-9]{64}$"}, + "from_version": {"type": "integer", "minimum": 1}, + "summary": {"type": "string", "minLength": 1, "maxLength": 4096}, + "operations": { + "type": "array", "minItems": 1, "maxItems": 128, + "items": { + "type": "object", + "required": ["op", "path"], + "properties": { + "op": {"type": "string", "enum": ["add", "replace", "remove"]}, + "path": { + "type": "string", + "pattern": "^/(name|purpose|operating_principles|boundaries|working_style/[A-Za-z0-9_.-]{1,128}|user_preferences/[A-Za-z0-9_.-]{1,128})$" + }, + "value": { + "oneOf": [ + {"type": "string", "maxLength": 16384}, + {"type": "array", "maxItems": 128, "items": {"type": "string", "minLength": 1, "maxLength": 8192}} + ] + } + }, + "additionalProperties": false + } + }, + "requested_by": {"type": ["string", "null"], "maxLength": 256}, + "approved_by": {"type": ["string", "null"], "maxLength": 256}, + "timestamp": {"type": "string", "format": "date-time", "maxLength": 64} + }, + "additionalProperties": false +} diff --git a/schemas/identity-envelope.schema.json b/schemas/identity-envelope.schema.json new file mode 100644 index 0000000..5cfda99 --- /dev/null +++ b/schemas/identity-envelope.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentmd.dev/schemas/identity-envelope.schema.json", + "title": "AgentMD Signed Identity Export Envelope", + "type": "object", + "required": [ + "schema_version", "agent_id", "exported_at", "record_count", "latest_version", + "latest_receipt_hash", "signer", "records", "envelope_hash", "signature" + ], + "properties": { + "schema_version": {"const": "identity-envelope.v1"}, + "agent_id": {"type": "string", "pattern": "^agentmd:[a-f0-9]{64}$"}, + "exported_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "record_count": {"type": "integer", "minimum": 1}, + "latest_version": {"type": "integer", "minimum": 1}, + "latest_receipt_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "signer": { + "type": "object", + "required": ["algorithm", "fingerprint", "public_key"], + "properties": { + "algorithm": {"const": "ed25519"}, + "fingerprint": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "public_key": {"type": "string", "minLength": 43, "maxLength": 64} + }, + "additionalProperties": false + }, + "records": {"type": "array", "minItems": 1, "maxItems": 10000, "items": {"type": "object"}}, + "envelope_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "signature": {"type": "string", "minLength": 86, "maxLength": 128} + }, + "additionalProperties": false +} diff --git a/tests/agentmd/test_agentmd_cli.py b/tests/agentmd/test_agentmd_cli.py index 6b2b27c..b1aabc2 100644 --- a/tests/agentmd/test_agentmd_cli.py +++ b/tests/agentmd/test_agentmd_cli.py @@ -4,11 +4,15 @@ import json from pathlib import Path import shutil +import subprocess import sys import tempfile import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from typer.testing import CliRunner sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @@ -1314,3 +1318,175 @@ def test_lead_compile_rejects_additional_provider_secrets(ws: Path) -> None: assert result.exit_code == 1 assert "lead_artifact_secret_detected" in result.stdout assert secret not in result.stdout + + +def write_identity_signing_key(path: Path) -> Path: + key = Ed25519PrivateKey.generate() + path.write_bytes( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + return path + + +def init_identity(root: Path, key_path: Path) -> dict[str, object]: + result = runner.invoke( + app, + [ + "identity", "init", "--root", str(root), "--name", "Continuing Agent", + "--purpose", "Preserve verified identity continuity", "--signing-key", str(key_path), "--json", + ], + ) + assert result.exit_code == 0, result.stdout + return json.loads(result.stdout) + + +def valid_identity_change(agent_id: str, from_version: int, **overrides: object) -> dict[str, object]: + change: dict[str, object] = { + "schema_version": "identity-change.v1", + "change_id": f"identity-change-{from_version}", + "agent_id": agent_id, + "from_version": from_version, + "summary": "Record a durable working-style preference.", + "operations": [{"op": "add", "path": "/working_style/tone", "value": "direct"}], + "requested_by": "test-suite", + "approved_by": "human-test", + "timestamp": "2026-09-29T20:00:00Z", + } + change.update(overrides) + return change + + +def test_identity_survives_restart_and_verifies(ws: Path) -> None: + key_path = write_identity_signing_key(ws / "identity-key.pem") + created = init_identity(ws, key_path) + + result = subprocess.run( + [ + sys.executable, "-m", "cli.agentmd", "identity", "verify", + "--root", str(ws), "--trust-fingerprint", str(created["fingerprint"]), "--json", + ], + cwd=REPO_ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + verified = json.loads(result.stdout) + assert verified["ok"] is True + assert verified["agent_id"] == created["agent_id"] + assert verified["version"] == 1 + assert verified["assurance"] == "trusted" + assert verified["state"]["name"] == "Continuing Agent" + + +def test_identity_export_import_preserves_signed_chain(ws: Path) -> None: + source = ws / "source" + destination = ws / "destination" + source.mkdir() + destination.mkdir() + key_path = write_identity_signing_key(ws / "identity-key.pem") + created = init_identity(source, key_path) + export_path = source / "identity-export.json" + + exported = runner.invoke( + app, + ["identity", "export", "--root", str(source), "--output", str(export_path), "--signing-key", str(key_path)], + ) + assert exported.exit_code == 0, exported.stdout + imported_path = destination / "identity-export.json" + shutil.copyfile(export_path, imported_path) + + wrong = runner.invoke( + app, + ["identity", "import", "--root", str(destination), "--input", str(imported_path), "--trust-fingerprint", "sha256:" + "0" * 64], + ) + assert wrong.exit_code == 1 + assert "identity_signer_fingerprint_mismatch" in wrong.stdout + assert not (destination / ".agentmd" / "identity" / "records").exists() + + imported = runner.invoke( + app, + ["identity", "import", "--root", str(destination), "--input", str(imported_path), "--trust-fingerprint", str(created["fingerprint"]), "--json"], + ) + assert imported.exit_code == 0, imported.stdout + payload = json.loads(imported.stdout) + assert payload["agent_id"] == created["agent_id"] + assert payload["version"] == 1 + assert payload["imported_records"] == 1 + + +def test_identity_invalid_change_writes_nothing(ws: Path) -> None: + key_path = write_identity_signing_key(ws / "identity-key.pem") + created = init_identity(ws, key_path) + secret = "sk-" + "abcdefghijklmnopqrstuvwxyz123456" + change_path = ws / "identity-change.json" + change_path.write_text( + json.dumps( + valid_identity_change( + str(created["agent_id"]), + 1, + operations=[{"op": "add", "path": "/user_preferences/api", "value": secret}], + ) + ), + encoding="utf-8", + ) + + result = runner.invoke( + app, + ["identity", "apply", "--root", str(ws), "--change", str(change_path), "--signing-key", str(key_path)], + ) + + assert result.exit_code == 1 + assert "identity_change_secret_detected" in result.stdout + assert secret not in result.stdout + assert len(list((ws / ".agentmd" / "identity" / "records").glob("*.json"))) == 1 + + +def test_identity_rollback_is_append_only(ws: Path) -> None: + key_path = write_identity_signing_key(ws / "identity-key.pem") + created = init_identity(ws, key_path) + change_path = ws / "identity-change.json" + change_path.write_text(json.dumps(valid_identity_change(str(created["agent_id"]), 1)), encoding="utf-8") + + applied = runner.invoke( + app, + ["identity", "apply", "--root", str(ws), "--change", str(change_path), "--signing-key", str(key_path)], + ) + assert applied.exit_code == 0, applied.stdout + rolled_back = runner.invoke( + app, + ["identity", "rollback", "--root", str(ws), "--version", "1", "--signing-key", str(key_path)], + ) + assert rolled_back.exit_code == 0, rolled_back.stdout + + history_result = runner.invoke(app, ["identity", "history", "--root", str(ws), "--json"]) + assert history_result.exit_code == 0, history_result.stdout + history = json.loads(history_result.stdout) + assert [item["version"] for item in history] == [1, 2, 3] + assert history[-1]["event"] == "rollback" + assert history[-1]["target_version"] == 1 + records = sorted((ws / ".agentmd" / "identity" / "records").glob("*.json")) + assert len(records) == 3 + + +def test_identity_corruption_fails_closed(ws: Path) -> None: + key_path = write_identity_signing_key(ws / "identity-key.pem") + created = init_identity(ws, key_path) + record_path = ws / ".agentmd" / "identity" / "records" / "00000001.json" + record = json.loads(record_path.read_text(encoding="utf-8")) + record["state"]["name"] = "Impostor" + record_path.write_text(json.dumps(record), encoding="utf-8") + + result = runner.invoke( + app, + ["identity", "verify", "--root", str(ws), "--trust-fingerprint", str(created["fingerprint"]), "--json"], + ) + + assert result.exit_code == 1 + assert "identity_state_hash_mismatch" in result.stdout + assert len(list((ws / ".agentmd" / "identity" / "records").glob("*.json"))) == 1 From 352b84af77733f6562fc598e6853e10ffdf8518f Mon Sep 17 00:00:00 2001 From: AgentMD local repair Date: Sat, 24 Oct 2026 12:50:00 -0500 Subject: [PATCH 4/5] fix: validate real CLI outcomes and harden artifact integrity --- .gitignore | 2 + PROJECT_STATUS.md | 103 ++----- README.md | 43 +-- RELEASE_CHECKLIST.md | 30 +- apps/api/requirements.txt | 16 +- cli/agentmd.py | 464 ++++++++++++++++++++++-------- docs/adoption_quickstart.md | 17 +- docs/cli_exit_codes.md | 6 + docs/compatibility_policy.md | 6 + docs/determinism.md | 6 + docs/local_execution.md | 26 ++ docs/proof_note.md | 205 ++----------- docs/receipt_integrity.md | 8 +- docs/security_hardening_todo.md | 107 +++---- docs/threat_model.md | 7 +- requirements-ci.in | 4 + requirements-ci.lock | 27 +- scripts/verify_local.py | 93 ++++++ tests/agentmd/test_agentmd_cli.py | 360 +++++++++++++++++++++++ 19 files changed, 1014 insertions(+), 516 deletions(-) create mode 100644 docs/local_execution.md create mode 100644 scripts/verify_local.py diff --git a/.gitignore b/.gitignore index d792db2..1b90fce 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ data/ !examples/lead-artifacts/** !examples/lead-output/ !examples/lead-output/** + +.agentmd/verification/ diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 6ea1d71..c76c04b 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -1,91 +1,30 @@ -# PROJECT_STATUS +# PROJECT_STATUS -## AgentMD Runtime security-hardening branch +Status date: 2026-10-24. Local review branch; no new release is implied. -Status date: 2026-09-28 +## Product and implemented behavior -## Product framing +AgentMD governs local context, normalizes tool-run artifacts and writes reproducible packets/receipts. The CLI performs real filesystem/schema/hash operations. `run` executes an explicit caller-provided local command with real terminal status and hash-only output receipts; it does not guess provider flags or pretend an adapter ran. -**AI Work Lead**: AgentMD compiles scattered AI/tool run artifacts into one deterministic, verified current-state packet with proof receipts. +- Published baseline: `v0.3.0-alpha.3`, `6602fc4874c1824faa4d2fd23d3ec560fd39db21`. +- Historical September 29 Windows CI: 41 CLI tests plus scanner, dependency checks and demo. That historical result does not clear new advisories. +- October review branch corrects final packet hashing, RFC3339 validation/ordering, failed workspace gates, bundle preflight/rollback, secret-key diagnostics and terminal-loop recommendations. +- Additional defensive changes: nested workspace-root checks, receipt-chain continuity, zero-evidence honesty and real explicit-command execution. +- Local compiler outputs: `.sticky/current-state.json`, `.sticky/current-state.md`, `.sticky/receipts/*.jsonl`. +- Actual-checkout verification: `python -m cli.agentmd run --task "verify this checkout" -- python scripts/verify_local.py`, then compile `.agentmd/verification/run.lead-artifact.json`. -Deployment model: **deployment-agnostic** (local, repo, CI, cloud, enterprise). +## What verification establishes -Current strongest claim: +Acceptance is correct results on real local work, rejection without inconsistent outputs, source/packet/receipt hash agreement, and truthful process outcomes—not a test-count target. Parser/security regressions deliberately use synthetic inputs. Current commands and limitations are in `docs/proof_note.md` and `docs/local_execution.md`. -- AgentMD compiles scattered AI/tool run artifacts into one deterministic current-state packet with proof receipts. +## Remaining blockers and non-goals -## What exists now +- Legacy API dependencies are not a supported production runtime: sentence-transformers requires a separately validated migration; the coupled OpenTelemetry/Firestore update passed offline dependency/client/serialization checks. Its direct audit gate remains failing, not bypassed. +- No real model-provider calls, provider credentials, hosted auth/account actions or cloud deployments were run. +- Skill edits verify supplied score/receipt/hash consistency, not the evaluator's truth or actual improvement; no optimizer/benchmark engine is implemented. +- Imported claims remain source assertions; hashes are not authentication or truth verification. +- No signature chain, external notarization, production-SaaS assurance or multi-user execution isolation. +- Windows wrapper execution and process behavior still need native-platform verification for these new changes. +- Account credential rotation/revocation and review/signature enforcement are not established by source cleanup. -- `agentmd lead compile` ingests `.json` / `.jsonl` artifacts via repeatable `--artifact` flags. -- Lead Artifact input contract is versioned as **Lead Artifact v1** (`schema_version: "lead-artifact.v1"`). -- Schema validation is strict and requires `jsonschema` (pinned in `requirements-cli.txt`). -- Invalid artifacts fail with explicit schema errors. -- Artifact paths, sizes, record counts, nesting, secrets, and output mutation boundaries are checked before writes. -- Lead receipts bind source hashes, chain to prior valid receipts, and treat source identity as asserted rather than authenticated. -- Accepted skill edits require a hash-bound `skill-validation.v1` receipt and may only mutate workspace-local `skills/*/SKILL.md`. -- Runtime outputs are emitted to: - - `.sticky/current-state.json` - - `.sticky/current-state.md` - - `.sticky/receipts/*.jsonl` -- Demo artifacts live in: - - `examples/lead-artifacts/` -- Example outputs live in: - - `examples/lead-output/` -- CI reproducibility workflow exists: - - `AgentMD Lead Demo` (`.github/workflows/agentmd-lead-demo.yml`) - -## Exact CLI demo command - -```powershell -.\agentmd.cmd lead compile --task "compile ai work lead state" --artifact examples/lead-artifacts/run-codex.jsonl --artifact examples/lead-artifacts/run-claude.json --artifact examples/lead-artifacts/run-gemini.jsonl -``` - -## Output artifacts - -Runtime outputs: - -- `.sticky/current-state.json` -- `.sticky/current-state.md` -- `.sticky/receipts/*.jsonl` - -Example/shareable outputs: - -- `examples/lead-output/current-state.example.json` -- `examples/lead-output/current-state.example.md` - -## Verification commands and results - -Commands: - -```powershell -python -m py_compile cli/agentmd.py -python -m pytest -q tests/agentmd/test_agentmd_cli.py -.\scripts\demo-lead-compile.ps1 -``` - -Results: - -- `py_compile`: PASS -- `pytest`: PASS (`36 passed`) -- demo script: PASS - -## Published checkpoints - -- `v0.2.0`: AI Work Lead + Lead Artifact v1 checkpoint. -- `v0.3.0-alpha.2`: gated skill edit alpha published before the September 2026 security audit. -- The local hardening branch is not a release until public `main` is remediated and fresh CI passes. - -## Intentionally out of scope - -- SaaS platform features -- dashboard expansion/redesign -- tool-specific adapters -- auth/accounts -- cloud service layer -- agent swarm behavior -- persona marketplace/product surfacing -- unrelated runtime refactors - -## Next recommended technical step (NOT IMPLEMENTED) - -Remediate public `main`, rotate/audit repository credentials and integrations, then run fresh CI and `AgentMD Lead Demo` proof. Do not tag until those gates pass. +No push, merge, tag or release is authorized by this local repair. diff --git a/README.md b/README.md index e8b3249..d8fed70 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,25 @@ -# AgentMD Runtime +# AgentMD Runtime [![CI](https://github.com/electricwolfemarshmallowhypertext/agentmd-runtime/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/electricwolfemarshmallowhypertext/agentmd-runtime/actions/workflows/ci.yml) [![AgentMD Lead Demo](https://github.com/electricwolfemarshmallowhypertext/agentmd-runtime/actions/workflows/agentmd-lead-demo.yml/badge.svg?branch=main)](https://github.com/electricwolfemarshmallowhypertext/agentmd-runtime/actions/workflows/agentmd-lead-demo.yml) [![Latest Release](https://img.shields.io/github/v/release/electricwolfemarshmallowhypertext/agentmd-runtime?display_name=tag)](https://github.com/electricwolfemarshmallowhypertext/agentmd-runtime/releases) -**AgentMD compiles scattered AI/tool runs into one verified current-state packet.** +**AgentMD compiles AI/tool run evidence into a reproducible current-state packet.** -It compiles scattered AI/tool runs into one verified current-state packet, with schema validation, deterministic hashes, and proof receipts. +Schema validation, deterministic hashes and receipts establish local integrity—not the truth of imported claims or authenticated provider identity. ![AgentMD Flow](docs/assets/agentmd-flow.png) ## Proof -- Current public release: `v0.3.0-alpha.2` -- `v0.2.0`: AI Work Lead checkpoint -- `v0.3.0-alpha.2`: CI-verified alpha with gated skill edit primitive -- Published alpha CI status predates the September 2026 security audit -- Local security-hardening branch tests: `36 passed` -- Public `main` remediation and fresh CI proof are required before the next release -- Deterministic demo script: `scripts/demo-lead-compile.ps1` -- Lead Artifact schema validation is enforced (`schemas/lead-artifact.schema.json`) -- Lead receipts include chained hashes, source artifact hashes, replay resistance, and advisory `trust-weight.v1` metadata -- Source identity is asserted, not authenticated; AgentMD does not convert self-reported status into an authorization score -- Technical proof note: `docs/proof_note.md` +- Public release baseline: `v0.3.0-alpha.3`, commit `6602fc4874c1824faa4d2fd23d3ec560fd39db21` (September 29, 2026). +- Its historical Windows CI and demo passed with 41 targeted CLI tests; that is not a current dependency-safety guarantee. +- October fixes on this review branch are local/unreleased. See `docs/proof_note.md` for outcome-based verification and remaining blockers. +- The compiler, file hashes, receipts and bounded skill edits are real local operations. +- `run` now executes caller-specified argv locally; provider-specific integrations are not implemented or verified. +- Example provider-named artifacts are fixtures, not evidence of calls to Codex, Claude or Gemini. +- Source identity is asserted, not authenticated; `what_is_true` means source-reported verified claims, never an authorization score. +- The legacy API dependency gate remains blocked; do not release on the basis of a passing CLI suite alone. ## Trust & adoption docs @@ -51,7 +48,7 @@ task ## AI Work Lead -`agentmd lead compile` turns scattered run artifacts into one verified operating picture with deterministic hashing and proof receipts. +`agentmd lead compile` turns scattered run artifacts into one evidence-labelled operating picture with deterministic hashing and proof receipts. ![Verified Current-State Packet](docs/assets/current-state-packet.png) @@ -76,7 +73,8 @@ Lead Artifact is the portable input contract for any tool (Codex, Claude, Gemini - Schema: `schemas/lead-artifact.schema.json` - Required version field: `schema_version: "lead-artifact.v1"` -- Strict validation dependency: `jsonschema` +- Strict validation dependency: `jsonschema`; calendar/timezone validation is built in and does not rely on optional format extras +- RFC3339 offsets and arbitrary fractional seconds are ordered chronologically; leap-second timestamps are rejected explicitly - Invalid artifacts fail compile with explicit schema errors - Artifact paths stay inside the workspace and hostile inputs are size/depth bounded - High-confidence credential patterns fail closed before outputs are written @@ -112,7 +110,7 @@ Scope intentionally not implemented yet: .\agentmd.cmd doctor .\agentmd.cmd resolve --task "review this repo for context drift" .\agentmd.cmd receipt -.\agentmd.cmd run --adapter codex --task "review this repo for context drift" +.\agentmd.cmd run --task "verify this checkout" -- python scripts/verify_local.py .\agentmd.cmd lead compile --task "compile ai work lead state" --artifact examples/lead-artifacts/run-codex.jsonl --artifact examples/lead-artifacts/run-claude.json --artifact examples/lead-artifacts/run-gemini.jsonl .\agentmd.cmd skill apply-edit --edit edits/validated-skill-edit.json ``` @@ -130,6 +128,15 @@ python -m pytest -q tests/agentmd/test_agentmd_cli.py .\scripts\demo-lead-compile.ps1 ``` +For real local evidence (not the canned parser demo): + +```bash +python -m cli.agentmd run --task "verify this checkout" -- python scripts/verify_local.py +python -m cli.agentmd lead compile --artifact .agentmd/verification/run.lead-artifact.json +``` + +The verification command runs the CLI checks, records actual Git diff/status and log hashes, and verifies selected context bytes before emitting its artifact. A zero-input compile reports zero evidence, never an invented run. See `docs/local_execution.md` for process, privacy, failure and sandbox boundaries. + CI installs from `requirements-ci.lock` with package hashes and runs dependency and repository security scans. Trust scoring is advisory metadata; it never grants execution permission. ## License @@ -138,4 +145,4 @@ AgentMD Runtime is source-available under **BUSL-1.1**. Commercial production use, hosted service use, resale, embedding into commercial products, or offering substantially similar functionality as a service requires a commercial license from the Licensor. -See `LICENSE`, `LICENSE.md`, and `NOTICE`. +See `LICENSE`, `LICENSE.md`, and `NOTICE`. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 24060a3..9765235 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,20 +1,14 @@ -# RELEASE_CHECKLIST +# Release checklist -## AgentMD Runtime v0.2.0 RC +Current published baseline: `v0.3.0-alpha.3` (`6602fc4874c1824faa4d2fd23d3ec560fd39db21`). This is the checklist for a future candidate, not evidence that it has been released. -- [ ] Run local syntax check: - - `python -m py_compile cli/agentmd.py` -- [ ] Run local CLI tests: - - `python -m pytest -q tests/agentmd/test_agentmd_cli.py` -- [ ] Run demo proof script: - - `.\scripts\demo-lead-compile.ps1` -- [ ] Verify GitHub Actions workflow run is green: - - `AgentMD Lead Demo` (`.github/workflows/agentmd-lead-demo.yml`) -- [ ] Verify workflow artifact upload contains: - - `.sticky/current-state.json` - - `.sticky/current-state.md` - - `.sticky/receipts/*.jsonl` -- [ ] Confirm clean git status: - - `git status --short` -- [ ] Create tag only after CI pass: - - `git tag v0.2.0` +- [ ] Review all changes against the published baseline; retain the independent audit evidence. +- [ ] Fresh hash-locked wheel install and `pip check` on supported OS/Python versions. +- [ ] Syntax and repository scanner pass; no secrets or unauthorized fixtures. +- [ ] All CLI regressions pass **and** real explicit-command outcomes are verified. +- [ ] Run `agentmd run --task "verify this checkout" -- python scripts/verify_local.py`; compile the generated artifact, not just canned provider fixtures. +- [ ] Independently verify source hashes, final packet hash, receipts/chain, repeat determinism and consistent failure outputs. +- [ ] Verify current CLI and API dependency audits; resolve the documented sentence-transformers migration blocker and verify the coordinated Google/telemetry update. Do not bypass them. +- [ ] Native Windows/Python 3.11 runner/wrapper proof and current exact-commit CI. +- [ ] Review account governance/credentials separately; source cleanup is not revocation proof. +- [ ] Obtain authorization for publication, then verify exact remote commit and CI before tagging. No tag/version is prescribed by this checklist. diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 173bf13..7be9711 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -20,22 +20,24 @@ faiss-cpu==1.7.4 # Vector indexing optimization # Authentication & Security firebase-admin==6.2.0 -python-jose==3.5.0 +# python-jose removed: unused; Firebase Admin verifies identity tokens. passlib==1.7.4 bcrypt==4.0.1 cryptography==50.0.1 # Enhanced security features # Database & Caching redis==5.0.1 -google-cloud-firestore==2.13.1 +google-cloud-firestore==2.34.1 cachetools==5.3.2 # Embeddings cache # Monitoring & Logging prometheus-client==0.18.0 python-json-logger==2.0.7 -opentelemetry-api==1.20.0 -opentelemetry-sdk==1.20.0 -opentelemetry-instrumentation-fastapi==0.41b0 +opentelemetry-api==1.45.1 +opentelemetry-sdk==1.45.1 +opentelemetry-instrumentation-fastapi==0.66b1 +opentelemetry-exporter-otlp-proto-grpc==1.45.1 +protobuf==6.33.6 # Compatible Google/OTel serialization intersection structlog==23.1.0 # Structured logging sentry-sdk==1.45.1 # Error tracking @@ -60,12 +62,12 @@ xxhash==3.4.1 # Fast hashing for caching # Performance & Optimization orjson==3.12.0 ujson==6.0.0 -aiohttp==3.14.3 +aiohttp==3.14.4 uvloop==0.19.0 # Event loop optimization httptools==0.6.1 # HTTP parsing # Development Tools -black==26.5.1 +black==26.10.1 isort==5.12.0 flake8==6.1.0 mypy==1.6.1 diff --git a/cli/agentmd.py b/cli/agentmd.py index 91a0afc..b55c87d 100644 --- a/cli/agentmd.py +++ b/cli/agentmd.py @@ -3,10 +3,16 @@ import hashlib import json +import math +import os +import signal +import threading +import time import re import subprocess import tempfile from datetime import datetime, timezone +from decimal import Decimal from pathlib import Path from typing import Any @@ -56,11 +62,6 @@ "context", } -ADAPTER_INSTRUCTIONS = { - "codex": "codex --task \"{task}\" --context-file \"{context_file}\"", - "claude": "claude code --prompt \"{task}\" --context-file \"{context_file}\"", - "gemini": "gemini --prompt \"{task}\" --context-file \"{context_file}\"", -} LEAD_ARTIFACT_SCHEMA_FILE = "schemas/lead-artifact.schema.json" SKILL_EDIT_SCHEMA_FILE = "schemas/skill-edit.schema.json" @@ -118,6 +119,116 @@ def _atomic_write_text(path: Path, text: str) -> None: tmp_path.unlink() +def _atomic_write_bundle(root: Path, outputs: list[tuple[Path, str]]) -> None: + """Stage all outputs, then replace; roll back ordinary I/O failures. + + This is not a filesystem transaction: concurrent writers and process/power + loss during replacement require external serialization/recovery. + """ + staged: list[tuple[Path, Path, Path | None]] = [] + replaced: list[tuple[Path, Path | None]] = [] + recovery_backups: list[Path] = [] + try: + # Validate every destination before creating directories or replacing files. + paths = [_require_path_within(p, root, "output_outside_workspace") for p, _ in outputs] + if len(set(paths)) != len(paths): + raise ValueError("output_bundle_duplicate_destination") + for path in paths: + if path.exists() and not path.is_file(): + raise ValueError("output_destination_not_file") + for path, (_, content) in zip(paths, outputs): + path.parent.mkdir(parents=True, exist_ok=True) + backup = None + with tempfile.NamedTemporaryFile(delete=False, dir=path.parent, suffix=".tmp") as tmp: + staged_path = Path(tmp.name) + staged.append((path, staged_path, backup)) + staged_path.write_text(content, encoding="utf-8", newline="\n") + if path.exists(): + with tempfile.NamedTemporaryFile(delete=False, dir=path.parent, suffix=".bak") as tmp: + backup = Path(tmp.name) + staged[-1] = (path, staged_path, backup) + backup.write_bytes(path.read_bytes()) + for path, staged_path, backup in staged: + _require_path_within(path, root, "output_outside_workspace") + staged_path.replace(path) + replaced.append((path, backup)) + except (OSError, ValueError) as error: + rollback_failed = False + for path, backup in reversed(replaced): + try: + if backup is None: + path.unlink(missing_ok=True) + else: + backup.replace(path) + except OSError: + rollback_failed = True + if backup is not None: + recovery_backups.append(backup) + if rollback_failed: + paths = ", ".join(_display_path(root, path) for path in recovery_backups) + raise ValueError(f"output_bundle_rollback_failed: preserve recovery backups: {paths}") from error + raise ValueError("output_bundle_write_failed: prior output files preserved") from error + finally: + for _, staged_path, backup in staged: + staged_path.unlink(missing_ok=True) + if backup is not None and backup not in recovery_backups: + backup.unlink(missing_ok=True) + + +def _timestamp_key(value: str) -> tuple[datetime, Decimal]: + """RFC3339 calendar timestamps, ordered by instant with exact fractional seconds. + + Leap seconds are rejected (Python datetime has no leap-second time scale). + An empty timestamp is only for legacy context records with no event time. + """ + if not value: + return datetime.min.replace(tzinfo=timezone.utc), Decimal(0) + match = re.fullmatch( + r"(\d{4}-\d{2}-\d{2})[Tt](\d{2}:\d{2}:\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})", value, flags=re.ASCII + ) + if match is None: + raise ValueError("artifact_timestamp_invalid: expected RFC3339 date-time with timezone") + day, time, fraction, offset = match.groups() + # datetime accepts offsets like +01:60; RFC3339 does not. + if offset.upper() != "Z" and (int(offset[1:3]) > 23 or int(offset[4:6]) > 59): + raise ValueError("artifact_timestamp_invalid: invalid timezone offset") + try: + instant = datetime.fromisoformat(day + "T" + time + ("+00:00" if offset.upper() == "Z" else offset)) + return instant.astimezone(timezone.utc), Decimal(fraction or "0") + except (ValueError, OverflowError) as error: + raise ValueError("artifact_timestamp_invalid: invalid calendar date-time") from error + + +def _format_checker() -> Any: + jsonschema = _require_jsonschema() + checker = jsonschema.FormatChecker() + + @checker.checks("date-time", raises=ValueError) + def date_time(value: Any) -> bool: + if not isinstance(value, str): + return True # The schema type constraint reports non-strings. + if not value: + return False + _timestamp_key(value) + return True + + return checker + + +def _schema_error_summary(error: Any) -> str: + # Never include instance values or instance property names in diagnostics. + # Schema paths/required field names come from the bundled trusted schema. + location = "/".join(str(part) for part in error.absolute_schema_path) + if error.validator == "required": + missing = [key for key in error.validator_value if key not in error.instance] + detail = "missing required fields: " + ", ".join(missing) + elif error.validator == "additionalProperties": + detail = "Additional properties are not allowed" + else: + detail = "constraint failed: " + str(error.validator) + return f"schema/{location} ({detail})" + + def _require_path_within(path: Path, parent: Path, error_code: str) -> Path: resolved_path = path.resolve() resolved_parent = parent.resolve() @@ -195,7 +306,7 @@ def _merge_dict(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any def _load_config(root: Path) -> dict[str, Any]: - path = root / "agentmd.yaml" + path = _require_path_within(root / "agentmd.yaml", root, "config_outside_workspace") if not path.exists(): return dict(DEFAULT_CONFIG) try: @@ -277,13 +388,19 @@ def _check_jsonl(path: Path) -> tuple[bool, str]: def _discover_context_files(root: Path) -> dict[str, list[Path]]: - return { + for name in ("AGENTS.md", "skills", "memory", "policies", "evals", "receipts"): + _require_path_within(root / name, root, "context_root_outside_workspace") + files = { "agents": [root / "AGENTS.md"], "skills": sorted((root / "skills").glob("*/SKILL.md")), "memory": sorted((root / "memory").glob("*")), "policies": sorted((root / "policies").glob("*")), "evals": sorted((root / "evals").glob("*")), } + for paths in files.values(): + for path in paths: + _require_path_within(path, root, "context_file_outside_workspace") + return files def validate_workspace(root: Path) -> dict[str, Any]: @@ -638,6 +755,7 @@ def write_receipt( task: str | None = None, adapter: str | None = None, phase: str | None = None, + execution: dict[str, Any] | None = None, ) -> tuple[Path, dict[str, Any]]: validation = validate_workspace(root) resolved, resolved_path = _load_resolved(root) @@ -690,6 +808,8 @@ def write_receipt( "warnings": [*validation["warnings"], *receipt_warnings], }, } + if execution is not None: + receipt["execution"] = {**execution, "workspace_validation_ok": validation["ok"]} receipt_material = json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode("utf-8") receipt["receipt_hash"] = hashlib.sha256(receipt_material).hexdigest() @@ -734,16 +854,9 @@ def _require_jsonschema() -> Any: def _validate_json_payload(payload: dict[str, Any], schema: dict[str, Any], error_prefix: str) -> list[str]: jsonschema = _require_jsonschema() - validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker()) - errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.path)) - out: list[str] = [] - for err in errors: - if err.path: - ptr = ".".join(str(part) for part in err.path) - else: - ptr = "$" - out.append(f"{error_prefix} at {ptr} ({err.message})") - return out + validator = jsonschema.Draft202012Validator(schema, format_checker=_format_checker()) + errors = sorted(validator.iter_errors(payload), key=lambda e: str(list(e.absolute_schema_path))) + return [f"{error_prefix} at {_schema_error_summary(err)}" for err in errors] def _load_skill_edit_payload(root: Path, edit_path: Path) -> dict[str, Any]: @@ -801,9 +914,10 @@ def _load_skill_validation_receipt( receipt_path = Path(receipt_path_raw) if receipt_path.is_absolute(): raise ValueError("skill_validation_receipt_absolute_path_forbidden") + validation_root = _require_path_within(root / "evals" / "skill-validation", root, "skill_validation_root_outside_workspace") receipt_file = _require_path_within( root / receipt_path, - root / "evals" / "skill-validation", + validation_root, "skill_validation_receipt_outside_evals", ) if receipt_file.suffix.lower() != ".json" or not receipt_file.is_file(): @@ -846,7 +960,8 @@ def apply_skill_edit(root: Path, edit_path: Path) -> tuple[str, Path, dict[str, skill_path = Path(skill_path_raw) if skill_path.is_absolute(): raise ValueError("skill_edit_absolute_path_forbidden") - skill_file = _require_path_within(root / skill_path, root / "skills", "skill_edit_path_outside_skills") + skills_root = _require_path_within(root / "skills", root, "skill_root_outside_workspace") + skill_file = _require_path_within(root / skill_path, skills_root, "skill_edit_path_outside_skills") relative_skill_path = skill_file.relative_to((root / "skills").resolve()) if len(relative_skill_path.parts) != 2 or relative_skill_path.name != "SKILL.md": raise ValueError("skill_edit_path_must_match: skills//SKILL.md") @@ -941,6 +1056,7 @@ def apply_skill_edit(root: Path, edit_path: Path) -> tuple[str, Path, dict[str, return "rejected", rejection_path, base_record +LEAD_TERMINAL_LOOP_STATUSES = {"closed", "completed", "done", "resolved", "cancelled", "canceled"} LEAD_VERIFIED_STATUSES = {"verified", "true", "confirmed", "pass", "passed"} LEAD_UNVERIFIED_STATUSES = {"unverified", "unknown", "pending", "needs_verification", "partial"} LEAD_CONTRADICTORY_STATUSES = {"contradicted", "false", "failed", "fail", "rejected"} @@ -1007,8 +1123,9 @@ def _lead_check_json_depth(value: Any, source: str) -> None: def _lead_secret_findings(value: Any, path: str = "$") -> list[str]: findings: list[str] = [] if isinstance(value, dict): - for key, item in value.items(): - findings.extend(_lead_secret_findings(item, f"{path}.{key}")) + for index, (key, item) in enumerate(value.items()): + findings.extend(_lead_secret_findings(str(key), f"{path}[key:{index}]")) + findings.extend(_lead_secret_findings(item, f"{path}[value:{index}]")) elif isinstance(value, list): for index, item in enumerate(value): findings.extend(_lead_secret_findings(item, f"{path}[{index}]")) @@ -1191,18 +1308,10 @@ def _lead_validate_artifact_schema(root: Path, raw: dict[str, Any], source_ref: "lead_artifact_schema_dependency_missing: jsonschema is required; install requirements-cli.txt" ) from e - validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker()) - errors = sorted(validator.iter_errors(raw), key=lambda e: list(e.path)) - out: list[str] = [] - for err in errors: - if err.path: - ptr = ".".join(str(part) for part in err.path) - else: - ptr = "$" - out.append(f"lead_artifact_schema_invalid: {source_ref} at {ptr} ({err.message})") - for finding in _lead_secret_findings(raw): - out.append(f"lead_artifact_secret_detected: {source_ref} at {finding}") - return out + findings = _lead_secret_findings(raw) + if findings: + return [f"lead_artifact_secret_detected: {source_ref} at {finding}" for finding in findings] + return _validate_json_payload(raw, schema, f"lead_artifact_schema_invalid: {source_ref}") def _lead_extract_files_touched(raw: dict[str, Any]) -> list[str]: @@ -1346,8 +1455,16 @@ def _lead_normalize_artifact( task = str(raw.get("task") or "").strip() timestamp = str(raw.get("timestamp") or raw.get("generated_at") or "").strip() + _timestamp_key(timestamp) adapter = str(raw.get("adapter") or raw.get("tool") or raw.get("chat") or raw.get("agent") or "").strip() - verification_status = _lead_status(raw.get("verification_status") or ((raw.get("validation") or {}).get("ok"))) + verification_status = _lead_status(raw.get("verification_status")) + execution = raw.get("execution") + if isinstance(execution, dict): + success = (execution.get("status") == "completed" and execution.get("launched") is True + and execution.get("returncode") == 0 and (raw.get("validation") or {}).get("ok") is True) + verification_status = "verified" if success else "contradicted" + if not success: + open_loops.append({"text": "Investigate local execution outcome: " + str(execution.get("status") or "unknown"), "status": "open"}) needs_human_approval = _lead_to_str_list(raw.get("human_approval_items")) needs_human_approval.extend(_lead_to_str_list(raw.get("needs_human_approval"))) @@ -1376,6 +1493,7 @@ def _lead_normalize_artifact( "contradictions": contradictions, "needs_human_approval": needs_human_approval, "verification_status": verification_status, + "execution": execution if isinstance(execution, dict) else None, "context_bundle_hash": str(raw.get("context_bundle_hash") or "") or None, "receipt_hash": str(raw.get("receipt_hash") or "") or None, "source_artifacts": _lead_to_str_list(raw.get("source_artifacts")), @@ -1394,10 +1512,8 @@ def _lead_collect_artifact_paths(root: Path, explicit: list[Path]) -> list[Path] discovered: list[Path] = [] discovered.extend(sorted((root / "receipts").glob("*.jsonl"))) - discovered.extend(sorted((root / ".sticky" / "receipts").glob("*.jsonl"))) - resolved = root / ".agentmd" / "resolved-context.json" - if resolved.exists(): - discovered.append(resolved) + # Compiler receipts and context manifests are provenance, not new tool runs. + # Feeding them back would manufacture evidence and recursively inflate state. contained = [_require_path_within(p, root, "artifact_path_outside_workspace") for p in discovered] unique = sorted({str(p): p for p in contained}.values(), key=lambda p: str(p).lower()) return unique @@ -1406,38 +1522,9 @@ def _lead_collect_artifact_paths(root: Path, explicit: list[Path]) -> list[Path] def _lead_compile_packet(root: Path, artifacts: list[dict[str, Any]], task_override: str | None) -> dict[str, Any]: validation = validate_workspace(root) - if not artifacts: - default_task = (task_override or "").strip() - synthetic_id_source = default_task or "no-artifacts" - synthetic = { - "run_id": hashlib.sha256(synthetic_id_source.encode("utf-8")).hexdigest()[:16], - "timestamp": "", - "source_artifact": "none", - "task": default_task or None, - "adapter": None, - "files_touched": [], - "claims_made": [], - "decisions_made": [], - "open_loops": [], - "contradictions": [], - "needs_human_approval": [], - "verification_status": "unknown", - "context_bundle_hash": None, - "receipt_hash": None, - "git_state": { - "available": False, - "commit": None, - "dirty": None, - "changed_files": [], - "untracked_files": [], - "reason": "no_artifacts", - }, - } - artifacts = [synthetic] - artifacts_sorted = sorted( artifacts, - key=lambda a: (str(a.get("timestamp") or ""), str(a.get("run_id") or ""), str(a.get("source_artifact") or "")), + key=lambda a: (_timestamp_key(str(a.get("timestamp") or "")), str(a.get("run_id") or ""), str(a.get("source_artifact") or "")), ) task_candidates = [str(a.get("task") or "").strip() for a in artifacts_sorted if a.get("task")] @@ -1505,17 +1592,19 @@ def _lead_compile_packet(root: Path, artifacts: list[dict[str, Any]], task_overr true_claims = _lead_dedupe_sorted(true_claims) unverified_claims = _lead_dedupe_sorted(unverified_claims) - next_clean_action = "Proceed to the next scoped task." + next_clean_action = "Proceed to the next scoped task." if artifacts_sorted else "Collect a tool-run artifact before choosing the next task." if needs_human_approval: next_clean_action = "Resolve human-approval items before the next run." elif contradictions: next_clean_action = "Resolve contradictions and update verified state." elif unverified_claims: next_clean_action = "Verify unverified claims." - elif open_loops: - next_clean_action = f"Close open loop: {open_loops[0]['text']}" + else: + active_loops = [loop for loop in open_loops if loop["status"].strip().lower() not in LEAD_TERMINAL_LOOP_STATUSES] + if active_loops: + next_clean_action = f"Close open loop: {active_loops[0]['text']}" - latest = artifacts_sorted[-1] + latest = artifacts_sorted[-1] if artifacts_sorted else {} latest_context_bundle_hash = None latest_receipt_hash = None for artifact in reversed(artifacts_sorted): @@ -1542,6 +1631,7 @@ def _lead_compile_packet(root: Path, artifacts: list[dict[str, Any]], task_overr "version": "0.2", "primitive": "ai_work_lead", "input_trust": "untrusted", + "evidence_status": "asserted_sources" if artifacts_sorted else "no_evidence", "task": task, "artifacts_ingested": len(artifacts_sorted), "artifact_sources": sorted({str(a.get("source_artifact") or "") for a in artifacts_sorted if a.get("source_artifact")}), @@ -1594,8 +1684,6 @@ def _lead_compile_packet(root: Path, artifacts: list[dict[str, Any]], task_overr }, } - material = json.dumps(packet, sort_keys=True, separators=(",", ":")).encode("utf-8") - packet["deterministic_hash"] = hashlib.sha256(material).hexdigest() return packet @@ -1622,6 +1710,8 @@ def lines_for_list(title: str, items: list[str]) -> list[str]: md: list[str] = [ "# Sticky Current State", "", + "Evidence: source assertions only; hashes verify integrity, not the truth of claims.", + "", f"Task: {_markdown_untrusted(packet.get('task'))}", f"Deterministic Hash: {packet.get('deterministic_hash')}", f"Artifacts Ingested: {packet.get('artifacts_ingested')}", @@ -1629,7 +1719,7 @@ def lines_for_list(title: str, items: list[str]) -> list[str]: ] md.extend(lines_for_list("What Changed", current.get("what_changed", []))) - md.extend(lines_for_list("What Is True", current.get("what_is_true", []))) + md.extend(lines_for_list("Source-Reported Verified Claims", current.get("what_is_true", []))) md.extend(lines_for_list("What Is Unverified", current.get("what_is_unverified", []))) md.extend(lines_for_list("What Contradicts Prior State", current.get("what_contradicts_prior_state", []))) md.extend(lines_for_list("What Needs Human Approval", current.get("what_needs_human_approval", []))) @@ -1734,21 +1824,23 @@ def _lead_latest_valid_receipt(root: Path) -> tuple[dict[str, Any] | None, list[ return None, [] warnings: list[str] = [] - for path in reversed(sorted(receipts_dir.glob("*.jsonl"))): + latest: dict[str, Any] | None = None + expected_previous: str | None = None + for path in sorted(receipts_dir.glob("*.jsonl")): try: path = _require_path_within(path, root, "receipt_history_outside_workspace") except ValueError: - warnings.append(f"receipt_history_outside_workspace: {_display_path(root, path)}") + warnings.append("receipt_history_outside_workspace") continue if path.stat().st_size > LEAD_MAX_ARTIFACT_BYTES: warnings.append(f"receipt_history_too_large: {_display_path(root, path)}") continue try: lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] - except OSError: + except (OSError, UnicodeError): warnings.append(f"receipt_history_unreadable: {_display_path(root, path)}") continue - for line in reversed(lines): + for line in lines: try: receipt = _strict_json_loads(line) except (json.JSONDecodeError, ValueError): @@ -1757,8 +1849,12 @@ def _lead_latest_valid_receipt(root: Path) -> tuple[dict[str, Any] | None, list[ if not isinstance(receipt, dict) or not _receipt_hash_matches(receipt): warnings.append(f"receipt_history_hash_mismatch: {_display_path(root, path)}") continue - return receipt, warnings - return None, warnings + if receipt.get("previous_receipt_hash") != expected_previous: + warnings.append(f"receipt_history_chain_mismatch: {_display_path(root, path)}") + continue + latest = receipt + expected_previous = str(receipt["receipt_hash"]) + return latest, warnings def _lead_latest_trust_history( @@ -1882,7 +1978,7 @@ def _lead_build_trust_report( ) -def _lead_write_receipt(root: Path, packet: dict[str, Any], command_run: str) -> tuple[Path, dict[str, Any]]: +def _lead_prepare_receipt(root: Path, packet: dict[str, Any], command_run: str) -> tuple[Path, dict[str, Any]]: proof = packet.get("proof", {}) git_state = proof.get("git_state", {}) timestamp = datetime.now(timezone.utc) @@ -1918,11 +2014,11 @@ def _lead_write_receipt(root: Path, packet: dict[str, Any], command_run: str) -> material = json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode("utf-8") receipt["receipt_hash"] = hashlib.sha256(material).hexdigest() - receipts_dir = root / ".sticky" / "receipts" - receipts_dir.mkdir(parents=True, exist_ok=True) + receipts_dir = _require_path_within(root / ".sticky" / "receipts", root, "lead_receipt_output_outside_workspace") filename = timestamp.strftime("%Y%m%dT%H%M%S%fZ.jsonl") out_path = _require_path_within(receipts_dir / filename, root, "lead_receipt_output_outside_workspace") - _atomic_write_text(out_path, json.dumps(receipt) + "\n") + if out_path.exists(): + raise ValueError("lead_receipt_already_exists") return out_path, receipt @@ -1932,6 +2028,8 @@ def compile_lead_state( artifact_paths: list[Path], validate_artifacts: bool = False, ) -> tuple[dict[str, Any], Path, Path, Path, dict[str, Any]]: + if _lead_secret_findings(task_override): + raise ValueError("lead_artifact_secret_detected: task override") artifacts: list[dict[str, Any]] = [] warnings: list[str] = [] schema_errors: list[str] = [] @@ -1940,7 +2038,13 @@ def compile_lead_state( source_sha256 = _sha256_file(artifact_path) if loaded_records else "" warnings.extend(loaded_warnings) for idx, (raw, source_ref) in enumerate(loaded_records, start=1): - if validate_artifacts: + if not validate_artifacts: + if raw.get("phase") == "pre": + continue # Prepared is not evidence that a command executed. + if raw.get("schema_version") != "lead-artifact.v1" and not isinstance(raw.get("execution"), dict): + warnings.append(f"artifact_metadata_not_run_evidence: {source_ref}") + continue + if validate_artifacts or raw.get("schema_version") == "lead-artifact.v1": try: schema_errors.extend(_lead_validate_artifact_schema(root, raw, source_ref)) except ValueError as e: @@ -1948,6 +2052,15 @@ def compile_lead_state( else: for finding in _lead_secret_findings(raw): schema_errors.append(f"lead_artifact_secret_detected: {source_ref} at {finding}") + execution = raw.get("execution") or {} + if (raw.get("phase") != "post" or not _receipt_hash_matches(raw) + or execution.get("status") not in {"completed", "failed", "launch_failed", "timed_out", "output_limit_exceeded", "output_stream_incomplete", "interrupted"} + or not isinstance(execution.get("launched"), bool) + or not isinstance(raw.get("validation"), dict) + or not isinstance((raw.get("validation") or {}).get("ok"), bool)): + schema_errors.append(f"lead_execution_receipt_invalid: {source_ref}") + if schema_errors: + continue artifacts.append( _lead_normalize_artifact( raw, @@ -1960,12 +2073,12 @@ def compile_lead_state( if validate_artifacts and warnings: raise ValueError("; ".join(warnings)) - if validate_artifacts and not artifacts: - raise ValueError("lead_artifact_input_empty: no valid artifact records loaded") - if schema_errors: raise ValueError("; ".join(schema_errors)) + if validate_artifacts and not artifacts: + raise ValueError("lead_artifact_input_empty: no valid artifact records loaded") + run_ids = [str(item.get("run_id") or "") for item in artifacts] duplicate_run_ids = sorted({run_id for run_id in run_ids if run_id and run_ids.count(run_id) > 1}) if duplicate_run_ids: @@ -1980,14 +2093,20 @@ def compile_lead_state( merged = sorted({*current_warnings, *warnings}) validation["warnings"] = merged + if not packet["validation"]["ok"]: + raise ValueError("lead_workspace_validation_failed: " + "; ".join(packet["validation"]["errors"])) + material = json.dumps(packet, sort_keys=True, separators=(",", ":")).encode("utf-8") + packet["deterministic_hash"] = hashlib.sha256(material).hexdigest() + sticky_dir = _require_path_within(root / ".sticky", root, "lead_output_outside_workspace") - sticky_dir.mkdir(parents=True, exist_ok=True) json_path = _require_path_within(sticky_dir / "current-state.json", root, "lead_output_outside_workspace") md_path = _require_path_within(sticky_dir / "current-state.md", root, "lead_output_outside_workspace") - _atomic_write_text(json_path, json.dumps(packet, indent=2)) - _atomic_write_text(md_path, _lead_current_state_markdown(packet)) - - receipt_path, receipt = _lead_write_receipt(root, packet, command_run="agentmd lead compile") + receipt_path, receipt = _lead_prepare_receipt(root, packet, command_run="agentmd lead compile") + _atomic_write_bundle(root, [ + (json_path, json.dumps(packet, indent=2)), + (md_path, _lead_current_state_markdown(packet)), + (receipt_path, json.dumps(receipt) + "\n"), + ]) return packet, json_path, md_path, receipt_path, receipt @@ -2093,34 +2212,149 @@ def receipt( typer.echo(f"receipt_hash: {data['receipt_hash']}") +def _execute_local_command(root: Path, argv: list[str], context: Path, task: str, timeout: float, output_limit: int) -> dict[str, Any]: + """Execute explicit argv; hash bounded output without retaining or displaying it. + + This is execution with the caller's privileges/environment, NOT a sandbox. + """ + result: dict[str, Any] = {"status": "launch_failed", "launched": False, "returncode": None} + exceeded = threading.Event() + streams: dict[str, dict[str, Any]] = {} + env = dict(os.environ) + env.update(AGENTMD_CONTEXT_FILE=str(context), AGENTMD_TASK=task) + try: + process = subprocess.Popen( + argv, cwd=root, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False, + start_new_session=(os.name == "posix"), + ) + except (OSError, ValueError): + return result + result["launched"] = True + + def digest_stream(name: str, pipe: Any) -> None: + digest = hashlib.sha256() + count = 0 + complete = False + try: + while True: + chunk = pipe.read1(65536) + if not chunk: + complete = True + break + remaining = max(0, output_limit - count) + digest.update(chunk[:remaining]) + count += len(chunk) + if count > output_limit: + exceeded.set() + break + except (OSError, ValueError): + complete = False + finally: + streams[name] = {"sha256": digest.hexdigest(), "bytes_observed": count, "hashed_bytes": min(count, output_limit), "complete": complete} + + readers = [threading.Thread(target=digest_stream, args=(name, pipe), daemon=True) + for name, pipe in (("stdout", process.stdout), ("stderr", process.stderr))] + for reader in readers: + reader.start() + + def terminate() -> None: + try: + if os.name == "posix": + os.killpg(process.pid, signal.SIGKILL) + else: + process.kill() + except ProcessLookupError: + pass + + deadline = time.monotonic() + timeout + status = "completed" + try: + while process.poll() is None: + if exceeded.is_set(): + status = "output_limit_exceeded" + terminate() + break + if time.monotonic() >= deadline: + status = "timed_out" + terminate() + break + time.sleep(0.01) + except KeyboardInterrupt: + status = "interrupted" + terminate() + process.wait() + for reader in readers: + reader.join(timeout=0.5) + if any(reader.is_alive() for reader in readers): + terminate() # Clean up POSIX descendants that inherited output pipes. + for reader in readers: + reader.join(timeout=0.5) + status = "output_stream_incomplete" + elif exceeded.is_set() and status == "completed": + status = "output_limit_exceeded" + elif status == "completed" and (len(streams) != 2 or not all(item["complete"] for item in streams.values())): + status = "output_stream_incomplete" + if status == "completed" and process.returncode != 0: + status = "failed" + result.update(status=status, returncode=process.returncode, output=streams) + for pipe in (process.stdout, process.stderr): + if pipe is not None and not any(reader.is_alive() for reader in readers): + pipe.close() + return result + + @app.command() def run( - adapter: str = typer.Option(..., "--adapter", help="Adapter to launch: codex|claude|gemini"), - task: str = typer.Option(..., "--task", help="Task to execute"), + task: str = typer.Option(..., "--task", help="Task passed as AGENTMD_TASK, never interpolated into a shell"), root: Path = typer.Option(Path("."), "--root", help="Workspace root"), + adapter: str | None = typer.Option(None, "--adapter", help="Optional caller-asserted label; does not select provider commands"), + timeout: float = typer.Option(300.0, "--timeout", min=0.1, max=3600.0, help="Maximum child runtime in seconds"), + output_limit: int = typer.Option(1048576, "--output-limit", min=1024, max=16777216, help="Maximum hashed bytes per stdout/stderr stream"), + command: list[str] = typer.Argument(None, help="Explicit executable and arguments after -- (no shell)"), ) -> None: + if not math.isfinite(timeout): + raise typer.BadParameter("timeout must be finite") + if not command: + raise typer.BadParameter("provide an explicit command after --; adapters are labels, not integrations") root = root.resolve() - adapter = adapter.lower().strip() - if adapter not in ADAPTER_INSTRUCTIONS: - raise typer.BadParameter("adapter must be one of: codex, claude, gemini") - - resolve_context(root, task) - write_receipt(root, command_run=f"agentmd run --adapter {adapter} --task {task}", task=task, adapter=adapter, phase="pre") - - context_file = ".agentmd/resolved-context.json" - launch = ADAPTER_INSTRUCTIONS[adapter].format(task=task.replace('"', '\\"'), context_file=context_file) - typer.echo("agentmd run v1 does not execute adapters yet.") - typer.echo(f"launch_instruction: {launch}") - - out_path, data = write_receipt( - root, - command_run=f"agentmd run --adapter {adapter} --task {task}", - task=task, - adapter=adapter, - phase="post", - ) + try: + if _lead_secret_findings({"task": task, "adapter": adapter}): + raise ValueError("run_secret_detected: do not put credentials in recorded task or adapter labels") + validation = validate_workspace(root) + if not validation["ok"]: + raise ValueError("run_workspace_validation_failed: " + "; ".join(validation["errors"])) + receipts_dir = _require_path_within(root / "receipts", root, "receipt_output_outside_workspace") + if receipts_dir.exists() and not receipts_dir.is_dir(): + raise ValueError("receipt_output_not_directory") + resolved, context = resolve_context(root, task) + execution = { + "argv_sha256": hashlib.sha256(json.dumps(command, separators=(",", ":")).encode()).hexdigest(), + "argc": len(command), "cwd": str(root), + "context_manifest_sha256": _sha256_file(context), + "context_bundle_hash": resolved["context_bundle_hash"], + "timeout_seconds": timeout, "output_limit_bytes_per_stream": output_limit, + "status": "prepared", "launched": False, + "privacy": "argv and output are hash-only; environment inherited; not sandboxed", + } + _, before = write_receipt(root, command_run="agentmd run", task=task, adapter=adapter, phase="pre", execution=execution) + outcome = _execute_local_command(root, command, context, task, timeout, output_limit) + execution = {**execution, **outcome, "pre_receipt_hash": before["receipt_hash"]} + out_path, data = write_receipt(root, command_run="agentmd run", task=task, adapter=adapter, phase="post", execution=execution) + except (ValueError, OSError) as error: + typer.echo(str(error)) + raise typer.Exit(code=1) + typer.echo(f"execution_status: {execution['status']}") + typer.echo(f"returncode: {execution['returncode']}") + typer.echo(f"workspace_validation_ok: {data['validation']['ok']}") typer.echo(f"receipt: {_relative(root, out_path)}") typer.echo(f"receipt_hash: {data['receipt_hash']}") + if execution["status"] in {"completed", "failed"}: + code = int(execution["returncode"] or 0) + if code == 0 and not data["validation"]["ok"]: + code = 1 + raise typer.Exit(code=code if code >= 0 else 128 - code) + raise typer.Exit(code={"launch_failed": 127, "timed_out": 124, "interrupted": 130}.get(execution["status"], 125)) if __name__ == "__main__": diff --git a/docs/adoption_quickstart.md b/docs/adoption_quickstart.md index 544cba1..c8d4ca5 100644 --- a/docs/adoption_quickstart.md +++ b/docs/adoption_quickstart.md @@ -11,14 +11,19 @@ This quickstart shows the minimum GitHub Actions integration for AgentMD current ## 1. Install dependencies ```powershell -python -m pip install --upgrade pip -python -m pip install -r requirements-cli.txt -python -m pip install pytest pyyaml +python -m pip install --only-binary=:all: --require-hashes -r requirements-ci.lock ``` ## 2. Run compile from CI -Use Lead Artifact inputs and compile a current-state packet: +For evidence from the actual checkout, run local verification and compile its generated artifact: + +```powershell +python -m cli.agentmd run --task "verify this checkout" -- python scripts/verify_local.py +python -m cli.agentmd lead compile --artifact .agentmd/verification/run.lead-artifact.json +``` + +For a **canned input/format demo only** (not live model-provider proof): ```powershell .\agentmd.cmd lead compile --task "compile ai work lead state" --artifact examples/lead-artifacts/run-codex.jsonl --artifact examples/lead-artifacts/run-claude.json --artifact examples/lead-artifacts/run-gemini.jsonl @@ -36,7 +41,7 @@ Upload generated outputs as workflow artifacts: ```yaml - name: Upload AgentMD proof artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1; same pin as repository workflow with: name: agentmd-proof if-no-files-found: error @@ -61,3 +66,5 @@ Upload generated outputs as workflow artifacts: - Missing required files fail with explicit `*_missing` messages. - Keep `if-no-files-found: error` in artifact upload so silent output loss fails fast. - On failure, capture step logs and uploaded artifacts from the last successful run for comparison. + +The legacy API audit has unresolved migration blockers; see `security_hardening_todo.md`. A passing compiler run is not a production release gate or authenticated truth proof. diff --git a/docs/cli_exit_codes.md b/docs/cli_exit_codes.md index 426e7af..3578be3 100644 --- a/docs/cli_exit_codes.md +++ b/docs/cli_exit_codes.md @@ -56,3 +56,9 @@ Notes: Notes: - `skill apply-edit` uses error code `1` for domain failures and disambiguates by message code. + +## run + +Explicit argv is required. The real child's return code propagates; negative signal codes map to `128 + signal`. Launch failure is `127`, timeout `124`, output limit/incomplete stream `125`, workspace or receipt error `1`, and usage error `2`. A prepared pre-receipt is not proof of execution; inspect the terminal `execution.status` and `returncode`. See `local_execution.md`. + +`lead compile` rejects a failed workspace before replacing outputs. With no discovered inputs it emits an explicit zero-evidence state, not a fabricated run. diff --git a/docs/compatibility_policy.md b/docs/compatibility_policy.md index 36fa093..25fceec 100644 --- a/docs/compatibility_policy.md +++ b/docs/compatibility_policy.md @@ -45,3 +45,9 @@ Placeholder policy (to finalize before stable release): - minimum deprecation window: `TBD` (recommend >= 90 days) - migration note required for every deprecation - removal only after deprecation window ends and migration guidance is published + +## October 2026 unreleased correction notes + +The former `run --adapter ...` command only printed a guessed invocation. It now requires explicit argv after `--`; `--adapter` is only an optional label. Migrate to the documented local-command contract in `local_execution.md`; provider integrations are not silently selected. + +Compiler output adds `evidence_status` and preserves execution outcomes. `what_is_true` remains for schema compatibility but is labelled source-reported verified claims in Markdown. Metadata-only/context/compiler/prepared receipts do not count as tool-run evidence; an empty discovery stays zero evidence. Invalid RFC3339 timestamps and failed required workspace validation now fail as the existing contract promised. Terminal loop status aliases are documented without narrowing the v1 input schema. diff --git a/docs/determinism.md b/docs/determinism.md index ca02595..b271358 100644 --- a/docs/determinism.md +++ b/docs/determinism.md @@ -53,3 +53,9 @@ Expected: - printed `deterministic_hash` To check deterministic repeat match, run the demo script twice without changing inputs and compare the printed deterministic hash values. + +## Timestamp and warning semantics + +Artifact timestamps are validated by the CLI's built-in RFC3339 calendar/timezone parser, regardless of optional jsonschema format packages. Offsets are compared by UTC instant with exact fractional-second ordering; deterministic run/source tie-breakers apply to equal instants. Leap seconds are rejected because the runtime has no leap-second time scale. All packet validation warnings are finalized before the deterministic hash is computed. A zero-input discovery produces zero artifacts and null event proof, not a synthetic observation. + +Loop statuses `closed`, `completed`, `done`, `resolved`, `cancelled`, and `canceled` (case-insensitive) are terminal. They remain visible as history but are not suggested for closure. Other status strings are conservatively actionable for compatibility. diff --git a/docs/local_execution.md b/docs/local_execution.md new file mode 100644 index 0000000..50a66c2 --- /dev/null +++ b/docs/local_execution.md @@ -0,0 +1,26 @@ +# Real local command execution + +```bash +python -m cli.agentmd run --task "verify this checkout" -- python scripts/verify_local.py +``` + +`run` requires explicit argv after `--`. It validates the workspace before launching, resolves a context manifest, writes a `prepared` receipt, starts the program with `shell=False` and `cwd` set to the workspace, and writes the actual terminal outcome. `--adapter` is an optional asserted label only; it never selects guessed provider flags. No command means a usage error, not pretend success. + +The child receives `AGENTMD_CONTEXT_FILE` (absolute JSON manifest path) and `AGENTMD_TASK` (task string). The manifest contains selected paths/hashes and selection metadata, **not concatenated file contents**; the child must read the selected workspace files. stdin is closed. No task/argument string is shell-interpolated. + +## Receipts and privacy + +Pre/post receipts bind the context manifest and bundle hash, canonical argv hash, argument count, cwd, timeout and terminal return code. Output is not printed or retained: stdout/stderr get SHA256 digests, byte counts and completeness flags. At most `--output-limit` bytes per stream are hashed (default 1 MiB); overflow terminates the process and is not a successful run. Digests are integrity metadata, not encryption or strong privacy for low-entropy values. + +Raw argv and output are intentionally omitted. Task, optional adapter label and workspace/context paths are recorded; do not put secrets there. Synthetic security test strings remain synthetic. Avoid secrets in command-line arguments even though receipts omit them, because OS process inspection can expose argv. + +## Failure and process boundaries + +- Actual process return codes propagate (negative signal returns map to `128 + signal`). +- Launch failure: 127; timeout: 124; interruption: 130; output-limit/incomplete stream: 125. +- Workspace/receipt failure: 1; usage error: 2. +- Default timeout is 300 seconds; permitted range is 0.1–3600 seconds. +- POSIX children use a new process group and are killed on timeout/overflow; Windows terminates the direct child only. This is not comprehensive process containment. +- A post-receipt write failure cannot undo an already executed command. Inspect the pre-receipt and workspace before retrying. + +**This is not a sandbox.** The child inherits the caller's environment, filesystem/network access and privileges; `shell=False` prevents interpolation, not arbitrary program effects. Review and authorize the exact executable/arguments. Provider API calls can incur costs and need separately supplied configuration/authorization; no provider integration is implied or verified by local smoke checks. diff --git a/docs/proof_note.md b/docs/proof_note.md index 7c4b1a6..24bf06a 100644 --- a/docs/proof_note.md +++ b/docs/proof_note.md @@ -1,189 +1,36 @@ -# AgentMD: compiling scattered AI runs into one verified current-state packet +# AgentMD proof and verification scope -## 1. Problem +## Published baseline versus this branch -AI-assisted work creates scattered artifacts across Codex, Claude, Gemini, chats, CI, and scripts. -The failure mode is losing the actual current state of work. +The public baseline is [v0.3.0-alpha.3](https://github.com/electricwolfemarshmallowhypertext/agentmd-runtime/releases/tag/v0.3.0-alpha.3), commit `6602fc4874c1824faa4d2fd23d3ec560fd39db21`. September 29, 2026 Windows CI logged 41 passing CLI tests, the indicator scanner and then-current dependency audits. The commit was verified; the annotated tag was unsigned. Historical proof artifacts are no longer available through the run artifact listing. Those facts do not assert a current passing security gate. -## 2. Claim +This October review branch is unreleased. Its acceptance checks require observable outcomes, not merely a green test count: -AgentMD compiles scattered AI/tool run artifacts into one deterministic current-state packet. +1. A real local command reads selected context files and verifies their manifest SHA256 values. +2. It executes syntax, repository scanner, CLI regressions and Git checks, records actual log/diff/status hashes, and emits a Lead Artifact from those results. +3. The compiler consumes that artifact; an independent reader checks artifact source hashes, canonical packet hash, receipt hash and predecessor links. +4. Repeated compiles with unchanged explicit inputs produce byte-identical JSON/Markdown, with no duplicate trust observations. +5. Invalid timestamps/workspaces, secret-shaped keys, bad destinations and ordinary write failures do not silently replace a valid output bundle. +6. A real failed command, launch failure, timeout and output-limit event are recorded truthfully and return nonzero. -## 3. Proof task +## Reproduce local evidence -Task: - -> Compile scattered Codex, Claude, and Gemini run artifacts into one verified current-state packet. - -Baseline: - -- human reads artifacts manually - -AgentMD path: - -- `agentmd lead compile` via the demo script - -## 4. Demo inputs and reference outputs - -Demo inputs: - -- `examples/lead-artifacts/run-codex.jsonl` -- `examples/lead-artifacts/run-claude.json` -- `examples/lead-artifacts/run-gemini.jsonl` - -Reference outputs: - -- `examples/lead-output/current-state.example.md` -- `examples/lead-output/current-state.example.json` - -## 5. Before → After - -Before: - -- scattered Codex/Claude/Gemini run artifacts - -After: - -- one compiled current-state packet -- one receipt trail - -## 6. Exact demo command - -```powershell -.\scripts\demo-lead-compile.ps1 +```bash +python -m pip install --only-binary=:all: --require-hashes -r requirements-ci.lock +python -m cli.agentmd doctor +python -m cli.agentmd run --task "verify this checkout" -- python scripts/verify_local.py +python -m cli.agentmd lead compile --artifact .agentmd/verification/run.lead-artifact.json +python -m cli.agentmd lead compile --artifact .agentmd/verification/run.lead-artifact.json ``` -## 7. Generated runtime outputs - -The compile writes: - -- `.sticky/current-state.json` -- `.sticky/current-state.md` -- `.sticky/receipts/*.jsonl` - -## 8. Packet excerpt (from example output) - -Excerpt source: `examples/lead-output/current-state.example.md` - -```text -Task: compile ai work lead state -Deterministic Hash: a9ccb7bd56bcf357aaf852683387eb72bc8b7f2f78b374ac55ad8680114282c4 -Artifacts Ingested: 3 - -## What Changed -- README.md -- apps/api/src/memory/server.py -- cli/agentmd.py - -## What Is True -- Endpoint returns latest receipt -- README includes AI Work Lead section - -## What Is Unverified -- All receipt tests passed in CI - -## What Contradicts Prior State -- Receipt includes git dirty status - -## What Needs Human Approval -- Approve endpoint release gate exception -- Hold release until endpoint smoke test exists - -## Open Loops -- Add integration coverage for lead compile (open) -- Confirm rollback procedure with ops (open) - -## Next Clean Action -- Resolve human-approval items before the next run. -``` - -## 9. What the packet contains - -- what changed -- what is true -- what is unverified -- contradictions -- human approval items -- open loops -- next clean action - -## 10. Minimum benchmark (single reproducible proof) - -| Metric | Result | -|---|---| -| Lead artifacts processed | 3 | -| Schema validation | PASS | -| Current-state packet generated | PASS | -| Deterministic hash repeat match | PASS | -| Receipts written | PASS | -| Trust metadata written | PASS | -| Contradictions preserved | PASS | -| Open loops preserved | PASS | -| Time to compile | measured locally; not benchmarked | -| Local security and CLI tests | 36 passed | -| Published alpha CI | historical proof; fresh post-audit run pending | - -## 11. Schema proof - -Lead Artifact v1 validates input artifacts. -Invalid artifacts fail clearly. - -Schema: - -- `schemas/lead-artifact.schema.json` - -## 12. CI and release proof - -- Published alpha CI: historical proof from before the September 2026 security audit -- AgentMD Lead Demo: fresh post-audit run pending -- GitHub Actions: `https://github.com/electricwolfemarshmallowhypertext/agentmd-runtime/actions` -- Release: `v0.3.0-alpha.2` -- Release URL: `https://github.com/electricwolfemarshmallowhypertext/agentmd-runtime/releases/tag/v0.3.0-alpha.2` - -Current verified facts: - -- local security and CLI tests: `36 passed` -- workflows: fresh post-audit CI and AgentMD Lead Demo proof pending -- latest release: `v0.3.0-alpha.2` -- latest release commit: `6efc66d` - -## 13. Skill gate proof - -Accepted skill edit writes: - -- `.sticky/skill-receipts/*.jsonl` - -Rejected skill edit writes: - -- `.sticky/rejected-skill-edits/*.jsonl` - -Schema: - -- `schemas/skill-edit.schema.json` - -## 14. What this proves - -- artifacts can be normalized -- schema validation works -- deterministic packet generation works -- proof receipts are written -- lead receipts preserve advisory source history without treating asserted identity as authenticated trust -- skill edits are gated by validation score and a hash-bound validation receipt - -## 15. What this does not prove yet - -- production SaaS readiness -- full adapter automation -- multi-user/team workflow -- autonomous optimizer loop -- cryptographic source identity and signed receipts -- independent security review of hostile-input controls +Real verification logs and report are in `.agentmd/verification/`; execution receipts are in `receipts/`; compiled packet and receipts are in `.sticky/`. They are local, ignored runtime evidence, not published proof. `scripts/demo-lead-compile.ps1` remains a canned input/format demonstration; printed repeated hashes from one execution are not independent repeated runs. -## 16. Boundaries +## What is not established -- Alpha release. -- Evaluation/research/internal testing use. -- Not production SaaS. -- Not an agent swarm. -- Not a hosted platform. -- Optimizer loop is not implemented yet. +- The legacy API is not fully installed/booted/validated; its remaining dependency audit findings remain a release blocker. See `docs/security_hardening_todo.md`. +- Example provider artifacts do not establish live provider calls. The local runner is not a tool-specific integration or sandbox. +- A source's `verified` claim is still asserted, not authenticated. Receipts establish bytes and local process outcomes, not semantic truth. +- Skill validation receipts bind externally supplied scores and hashes. They do not execute or authenticate an evaluator or prove improvement. +- Multi-output replacement rolls back ordinary I/O errors, not process/power loss or concurrent writers. Use one writer per workspace. +- The specific earlier blocked skill-root symlink probe was not executed/retried; nested-root checks are defensive code changes, not a claim that that probe was verified. +- New Windows runtime/wrapper execution, production credentials, account governance and signed attestation are not covered by Linux local evidence. diff --git a/docs/receipt_integrity.md b/docs/receipt_integrity.md index 3a0e2c4..b2816fa 100644 --- a/docs/receipt_integrity.md +++ b/docs/receipt_integrity.md @@ -35,7 +35,7 @@ Depending on receipt type, provenance includes: - advisory trust metadata (`trust-weight.v1` source history, current observations, and unverified-source flags) - skill edit id/type/reason/score delta/edit hash and validation receipt hash (for skill gate records) -Lead compilation verifies the latest receipt hash before using its trust history. Invalid or modified receipts are ignored and reported as validation warnings. Replayed artifact/run pairs are not counted again. Trust metadata remains advisory and source identity is explicitly classified as asserted. +Lead compilation verifies receipt hashes and chronological predecessor continuity from a retained genesis before using history. Orphaned/forked links are not consumed. Invalid or modified receipts are ignored and reported as validation warnings. Replayed artifact/run pairs are not counted again. Trust metadata remains advisory and source identity is explicitly classified as asserted. ## Tamper limitations @@ -56,3 +56,9 @@ Limitations: - receipt provenance is not anchored to an external immutable ledger For stable release hardening, signed receipts and external attestation can be added as later phases. + +## Output consistency + +Lead compilation finalizes validation warnings before hashing the packet. It preflights packet/Markdown/receipt destinations, prepares all bytes, then replaces the output bundle. Ordinary write errors trigger rollback; a failed rollback preserves backup files and reports their recovery paths. This is not a crash-consistent filesystem transaction, and concurrent writers are unsupported. + +Execution receipts distinguish prepared, completed, failed, launch-failed and timeout outcomes; see `local_execution.md`. Raw command argv/output are not stored. Skill-edit scores remain externally asserted even when their receipt hash matches. diff --git a/docs/security_hardening_todo.md b/docs/security_hardening_todo.md index 0e98da5..0e50900 100644 --- a/docs/security_hardening_todo.md +++ b/docs/security_hardening_todo.md @@ -1,87 +1,44 @@ -# AgentMD Security Hardening TODO +# Security hardening status and remaining gates -Status: local hardening verified; public repository remediation pending +Updated 2026-10-24. Separate historical cleanup, local fixes, and unverified administration. -This checklist records the September 2026 security audit and is the gate for the -next network operation or release. +## Published source baseline -## Remote finding +The September audit originally inspected an older remote head (`0d4eab0`) with canary/hooks/package files. That historical inventory is not the current tree. Published `v0.3.0-alpha.3`/main baseline `6602fc4874c1824faa4d2fd23d3ec560fd39db21` contains no root `canary.js`, root `package.json`, or `.githooks/`; adoption, exit-code, determinism, receipt and threat-model docs are restored. September 29 exact-commit CI logged 41 passing CLI tests and then-current audits. -- Last inspected remote head: `origin/main` at `0d4eab0`. -- Clean merge base: `c498d47653761b351a3fe0ba441f3fa9e92c431b`. -- Divergence at inspection: local branch 1 commit ahead; remote 23 commits ahead. -- The 23 remote-only commits are unsigned and share the message `chore: sync editor configuration`. -- Remote-only files: `.githooks/pre-commit`, `.githooks/post-merge`, `canary.js`, and `package.json`. -- The hook and npm lifecycle scripts decode and invoke an outbound HTTP command. -- Those files are absent locally, `core.hooksPath` is unset, and only sample hooks exist in `.git/hooks`. +This does **not** establish historical credential safety or account cleanup: -## P0: Repository containment +- [ ] Independently confirm unauthorized apps/tokens/deploy keys/sessions/webhooks were audited and revoked. +- [ ] Confirm credential rotation where needed; no production credentials were inspected in this local repair. +- [ ] Require substantive review/signature enforcement before release. The observed main ruleset required PR/checks but zero reviewer approvals; the release tag was unsigned. -- [x] Fetch and inspect `origin/main` without merging it. -- [x] Confirm the local working tree does not contain the outbound canary files. -- [x] Confirm no custom Git hooks are active locally. -- [x] Isolate work on `codex/security-hardening-2026` from the last clean lineage. -- [ ] Remove `.githooks/`, `canary.js`, and the introduced `package.json` from public `main`. -- [ ] Restore trust/adoption documentation deleted by the remote commits. -- [ ] Audit and revoke unauthorized GitHub apps, tokens, deploy keys, sessions, and webhooks. -- [ ] Protect `main` with reviewed changes and signed provenance. +## October local branch fixes (unreleased) -## P0: File mutation boundaries +- Final packet hashes cover warning fields and receipts bind that final hash. +- Timestamp format/calendar validation works without optional jsonschema extras; ordering uses UTC instant, including exact fractional seconds. +- Failed workspace validation prevents lead output replacement. +- Bundle preflight/staging/ordinary-error rollback preserves prior outputs; failed rollback retains recovery backups. No power-loss/concurrent-writer atomicity claim. +- Synthetic secret-shaped keys and values are rejected without echo; schema diagnostics omit instance values. +- Terminal loops are not recommended for closure. +- Nested skill/evaluator roots are checked against the workspace. The previously blocked specific skill-root symlink probe was not rerun; no runtime proof for that probe is claimed. +- Receipt history must have valid hashes and continuous retained predecessor links; unsigned replacement/recomputation remains possible. +- Empty discovery remains zero evidence, compiler receipts do not feed themselves back, and prepared receipts are not successful execution evidence. +- Local `run` now executes explicit argv and records actual failures/timeouts; it is not a sandbox/provider integration. -- [x] Restrict skill edits to workspace-local `skills/*/SKILL.md` files. -- [x] Reject absolute paths, traversal, and symlink escapes. -- [x] Write accepted skill edits atomically and verify the resulting hash. -- [x] Require hash-bound validation evidence before accepting an edit. +## Dependency gate — not yet green -## P1: Hostile artifact handling +The October advisory refresh found new issues despite September's successful audits. -- [x] Enforce artifact byte, record, nesting, string, and collection limits. -- [x] Reject malformed JSONL atomically instead of partially compiling it. -- [x] Restrict artifact paths to the workspace unless explicitly trusted later. -- [x] Reject symlink escapes and duplicate run IDs. -- [x] Keep executable and binary serialization formats unsupported. +- CLI/CI lock: certifi updated to `2026.10.19`, filelock to `4.2.1`; hash-lock installation and current audit must pass on the final branch. Colorama is retained explicitly for Windows hash-locked installs. +- Legacy API: unused `python-jose` removed (Firebase Admin handles token verification); aiohttp updated to `3.14.4`, Black to `26.10.1` with focused genuine local smoke checks. +- **Blocked: sentence-transformers `2.2.2`.** The scanner suggests `5.6.0`, but upstream says trust enforcement for local custom modules begins in `6.0.0`. A blind major upgrade is not compatibility evidence. Existing API issues include the memory-engine exception import, legacy Chroma configuration, and 384-vs-1024 embedding dimensions. A real local-model/storage migration is needed; no model was downloaded or invoked for this repair. +- **Coordinated OpenTelemetry/Firestore update:** API/SDK/OTLP exporter `1.45.1`, instrumentation `0.66b1`, Firestore `2.34.1`, protobuf `6.33.6`. The old Firestore `<5` protobuf conflict was resolved by a tested compatible Firestore update. Real offline client/protobuf roundtrips and instrumented HTTP/in-memory span export passed with existing Firebase Admin/AI Platform pins; this is not a live Firestore service or full API boot test. +- The API direct audit remains intentionally failing while those pins remain; the CI gate has not been removed/ignored. Direct auditing does not cover full transitive installation or production reachability. -## P1: Schema hardening +## Required before release -- [x] Close undeclared top-level Lead Artifact fields. -- [x] Validate RFC 3339 timestamps and SHA-256 hash formats. -- [x] Add practical `maxLength` and `maxItems` constraints. -- [x] Add stable claim/evidence provenance fields without weakening v1 validation. - -## P1: Receipt and trust integrity - -- [x] Verify historical receipt hashes before consuming trust history. -- [x] Chain receipts with `previous_receipt_hash`. -- [x] Bind artifact hashes to receipts. -- [x] Deduplicate trust observations by artifact hash and `run_id`. -- [x] Separate asserted claim status from evaluator-observed status. -- [x] Keep trust advisory; never use it as execution authorization. -- [x] Document that asymmetric signing and external attestation remain future work. - -## P1: Prompt-injection containment - -- [x] Mark imported artifact content as untrusted data with per-claim provenance. -- [x] Sanitize generated Markdown/HTML output. -- [x] Prevent artifact text from becoming commands, policy, or permissions. -- [x] Preserve explicit human-approval items; AgentMD v0.x does not execute sensitive actions. - -## P1: CI and dependency supply chain - -- [x] Pin GitHub Actions to verified full commit SHAs. -- [x] Set workflow permissions to `contents: read`. -- [x] Disable checkout credential persistence. -- [x] Add job timeouts. -- [x] Install dependencies from a hash-locked requirements file. -- [x] Remove unpinned workflow-only Python installs and pip self-upgrades. -- [x] Add dependency auditing and repository secret/indicator scanning gates. -- [x] Upload only allowlisted proof artifacts. - -## Release gate - -- [x] `python -m py_compile cli/agentmd.py scripts/security_scan.py` -- [x] `python -m pytest -q tests/agentmd/test_agentmd_cli.py` (`36 passed` on Python 3.11) -- [x] `./scripts/demo-lead-compile.ps1` -- [x] Dependency audit passes (`pip-audit`: no known vulnerabilities). -- [x] Security regression tests pass. -- [x] Final diff contains no outbound canary indicators or secrets. -- [ ] No push, tag, or release until the local gate is complete. +- [ ] Resolve remaining legacy API dependency gates, or obtain an explicit product/support-scope decision; do not silently suppress the gate. +- [ ] Re-run fresh hash-locked install, CLI outcomes, real local verification artifact, compiler repeat/integrity checks and all dependency audits on the final candidate. +- [ ] Run new behavior and wrappers natively on Windows/Python 3.11; Linux verification is not a Windows claim. +- [ ] Review local diff, unsigned-receipt limitations and all account-level unknowns. +- [ ] Obtain separate authorization for push, PR, merge, tag or release. diff --git a/docs/threat_model.md b/docs/threat_model.md index 6129b56..4ffe378 100644 --- a/docs/threat_model.md +++ b/docs/threat_model.md @@ -47,7 +47,7 @@ timeouts, vulnerability auditing, and repository indicator scanning. | Markdown/HTML injection | untrusted packet text is escaped in Markdown output | | Trust-score replay | source hash and run ID observations are deduplicated | | Receipt-history modification | receipt hash verification and previous-hash chaining | -| Self-asserted skill score | accepted edits require a bound validation receipt | +| Self-asserted skill score | accepted edits require a bound external validation receipt, not proof of evaluator honesty | | CI dependency substitution | exact Action SHAs and hash-locked Python packages | | Executable model/data formats | unsupported; AgentMD accepts JSON/JSONL artifacts only | @@ -85,12 +85,11 @@ Primary sources: - No cryptographic source identity for AI/tool artifacts yet. - No asymmetric receipt or evaluator signatures yet. -- No sandboxed adapter execution because adapters are not executed in v0.x. +- No sandboxed execution: explicit local commands run with caller privileges and inherited environment. Tool-specific provider integration is not implied. - No guarantee against a malicious actor who controls both the workspace and the signing or CI identity planned for future releases. - No production SaaS security claim. -- No runtime egress sandbox; AgentMD v0.x does not execute adapters, and CI - network policy remains a repository/runner control. +- No runtime egress sandbox; caller-specified commands can access networks according to the caller environment. CI network policy remains a repository/runner control. ## Required human gates diff --git a/requirements-ci.in b/requirements-ci.in index 94d320e..04c5e57 100644 --- a/requirements-ci.in +++ b/requirements-ci.in @@ -1,3 +1,7 @@ -r requirements-cli.txt pip-audit==2.10.1 pytest==9.0.3 +# Keep the Windows pytest/Click dependency in cross-platform hash-locked installs. +colorama==0.4.6 +# pip-api requires pip; pin it so clean cross-platform wheel resolution is hash-complete. +pip==26.2.2 diff --git a/requirements-ci.lock b/requirements-ci.lock index 29f571a..0cdcef0 100644 --- a/requirements-ci.lock +++ b/requirements-ci.lock @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile --generate-hashes --output-file=requirements-ci.lock requirements-ci.in +# pip-compile --allow-unsafe --generate-hashes --output-file=requirements-ci.lock requirements-ci.in # annotated-doc==0.0.5 \ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ @@ -24,9 +24,9 @@ cachecontrol[filecache]==0.14.4 \ # via # cachecontrol # pip-audit -certifi==2026.7.22 \ - --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ - --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 +certifi==2026.10.19 \ + --hash=sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae \ + --hash=sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b # via requests charset-normalizer==3.5.1 \ --hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \ @@ -209,7 +209,7 @@ click==8.5.0 \ colorama==0.4.6 \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 - # via pytest + # via -r requirements-ci.in cyclonedx-python-lib==11.12.0 \ --hash=sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54 \ --hash=sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf @@ -218,9 +218,9 @@ defusedxml==0.7.1 \ --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 # via py-serializable -filelock==4.0.5 \ - --hash=sha256:2b155f098c4f285fb41954a22c616c4e8a0635b78c184338ba3023c1c91a4b4d \ - --hash=sha256:be9166068623636cedcad7b2ef735b04e5fb4344d978e09fd6fbd6ede06f14f0 +filelock==4.2.1 \ + --hash=sha256:7be2662e9e826b9bba28afc0f843a123280d799a2ea44bb5d35ccec67905ae44 \ + --hash=sha256:c4c937ccac2a813cee448e662a56366cfb374c0f8702e75f295c412cc1c5d5a3 # via cachecontrol idna==3.20 \ --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ @@ -679,7 +679,10 @@ urllib3==2.8.0 \ --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 # via requests -# WARNING: The following packages were not pinned, but pip requires them to be -# pinned when the requirements file includes hashes and the requirement is not -# satisfied by a package already installed. Consider using the --allow-unsafe flag. -# pip +# The following packages are considered to be unsafe in a requirements file: +pip==26.2.2 \ + --hash=sha256:3a8e5c800492787f33a4114ef54444546c340df428974ebba17e0fd47e11af2f \ + --hash=sha256:fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70 + # via + # -r requirements-ci.in + # pip-api diff --git a/scripts/verify_local.py b/scripts/verify_local.py new file mode 100644 index 0000000..baa1a0b --- /dev/null +++ b/scripts/verify_local.py @@ -0,0 +1,93 @@ +"""Produce a Lead Artifact from real, offline CLI verification of this checkout. + +Run through `agentmd run -- ...` to bind the actual subprocess outcome to a +receipt. This is not a model/provider integration or an independent attestation. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +from datetime import datetime, timezone + + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / ".agentmd" / "verification" + + +def sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def main() -> int: + # No environment-specific output override: all evidence remains workspace-local. + OUT.resolve().relative_to(ROOT.resolve()) + OUT.mkdir(parents=True, exist_ok=True) + context_raw = os.environ.get("AGENTMD_CONTEXT_FILE") + if not context_raw: + raise SystemExit("run this script through agentmd run to supply a context manifest") + context = Path(context_raw).resolve() + context.relative_to(ROOT.resolve()) + manifest = json.loads(context.read_text(encoding="utf-8")) + verified_context = [] + for item in manifest["selected"]: + path = (ROOT / item["path"]).resolve() + path.relative_to(ROOT.resolve()) + actual = sha(path.read_bytes()) + if actual != item["sha256"]: + raise SystemExit("selected context hash mismatch") + verified_context.append({"path": item["path"], "sha256": actual}) + + commands = [ + ("syntax", [sys.executable, "-m", "py_compile", "cli/agentmd.py", "scripts/security_scan.py", "scripts/verify_local.py"]), + ("security_scan", [sys.executable, "scripts/security_scan.py"]), + ("cli_regressions", [sys.executable, "-m", "pytest", "-q", "tests/agentmd/test_agentmd_cli.py"]), + ("diff_check", ["git", "diff", "--check"]), + ("git_diff", ["git", "diff", "--binary", "HEAD"]), + ("git_status", ["git", "status", "--porcelain"]), + ] + checks = [] + for name, argv in commands: + result = subprocess.run(argv, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120, check=False) + path = OUT / (name + ".log") + path.resolve().relative_to(ROOT.resolve()) + path.write_bytes(result.stdout) + checks.append({"name": name, "argv": argv, "returncode": result.returncode, + "log": path.relative_to(ROOT).as_posix(), "sha256": sha(result.stdout)}) + commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + changed = subprocess.check_output(["git", "diff", "--name-only", "HEAD"], cwd=ROOT, text=True).splitlines() + untracked = subprocess.check_output(["git", "ls-files", "--others", "--exclude-standard"], cwd=ROOT, text=True).splitlines() + report = {"checks": checks, "context": verified_context, "script_sha256": sha(Path(__file__).read_bytes()), + "context_manifest_sha256": sha(context.read_bytes()), "git_commit": commit} + report_bytes = (json.dumps(report, indent=2) + "\n").encode() + report_path = OUT / "verification-report.json" + report_path.resolve().relative_to(ROOT.resolve()) + report_path.write_bytes(report_bytes) + ok = all(check["returncode"] == 0 for check in checks) + artifact = { + "schema_version": "lead-artifact.v1", "run_id": "local-verification-" + sha(report_bytes)[:20], + "tool": "agentmd-local-verification", "task": "Verify actual AgentMD checkout and CLI outcomes", + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "claims": [{"claim": f"{check['name']} exited {check['returncode']}; log SHA256 {check['sha256']}", + "verification_status": "verified" if check["returncode"] == 0 else "contradicted"} for check in checks], + "decisions": [], "files_touched": sorted(set(changed + untracked)), "open_loops": [], + "contradictions": [] if ok else ["Local verification has failed commands"], + "human_approval_items": ["Review local changes before any push, merge or release"], + "verification_status": "verified" if ok else "failed", + "context_bundle_hash": manifest["context_bundle_hash"], + "source_artifacts": [report_path.relative_to(ROOT).as_posix(), *[check["log"] for check in checks]], + "git_state": {"available": True, "commit": commit, "dirty": bool(changed or untracked), + "changed_files": changed, "untracked_files": untracked, "reason": None}, + } + artifact_path = OUT / "run.lead-artifact.json" + artifact_path.resolve().relative_to(ROOT.resolve()) + artifact_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8") + print("actual verification artifact:", artifact_path.relative_to(ROOT).as_posix()) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/agentmd/test_agentmd_cli.py b/tests/agentmd/test_agentmd_cli.py index 6b2b27c..d1443ae 100644 --- a/tests/agentmd/test_agentmd_cli.py +++ b/tests/agentmd/test_agentmd_cli.py @@ -2,6 +2,7 @@ import hashlib import json +import os from pathlib import Path import shutil import sys @@ -1314,3 +1315,362 @@ def test_lead_compile_rejects_additional_provider_secrets(ws: Path) -> None: assert result.exit_code == 1 assert "lead_artifact_secret_detected" in result.stdout assert secret not in result.stdout + + +def _canonical_hash(value: dict, field: str) -> str: + payload = {key: item for key, item in value.items() if key != field} + return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def test_lead_warning_packet_and_receipt_bind_final_bytes(ws: Path) -> None: + scaffold_workspace(ws) + (ws / "receipts" / "bad.jsonl").write_text("NOT JSON\n") + result = runner.invoke(app, ["lead", "compile", "--root", str(ws)]) + assert result.exit_code == 0, result.stdout + packet = json.loads((ws / ".sticky/current-state.json").read_text()) + receipt = json.loads(next((ws / ".sticky/receipts").glob("*.jsonl")).read_text()) + assert packet["validation"]["warnings"] + assert packet["deterministic_hash"] == _canonical_hash(packet, "deterministic_hash") + assert receipt["current_state_hash"] == packet["deterministic_hash"] + assert receipt["receipt_hash"] == _canonical_hash(receipt, "receipt_hash") + + +@pytest.mark.parametrize("timestamp", ["not-a-date", "2026-02-30T00:00:00Z", "2026-10-24", "2026-10-24T00:00:00", "2026-10-24T00:00:00+01:60", "2026-10-24T24:00:00Z", "2026-10-24T10:00:00.١Z"]) +def test_lead_timestamp_validation_does_not_depend_on_optional_jsonschema_formats(ws: Path, timestamp: str) -> None: + scaffold_workspace(ws) + (ws / "input.json").write_text(json.dumps(valid_lead_artifact(timestamp=timestamp))) + result = runner.invoke(app, ["lead", "compile", "--root", str(ws), "--artifact", "input.json"]) + assert result.exit_code == 1 + assert "schema_invalid" in result.stdout + assert not (ws / ".sticky/current-state.json").exists() + + +@pytest.mark.parametrize("earlier,later", [ + ("2026-10-24T23:00:00+14:00", "2026-10-24T10:00:00Z"), + ("2026-10-24T10:00:00.0000001Z", "2026-10-24T10:00:00.0000002Z"), +]) +def test_lead_uses_chronological_instants_for_task_proof_and_loop_state(ws: Path, earlier: str, later: str) -> None: + scaffold_workspace(ws) + (ws / "input.json").write_text(json.dumps([ + valid_lead_artifact("z-earlier", timestamp=earlier, task="OLDER", open_loops=[{"text": "same loop", "status": "open"}]), + valid_lead_artifact("a-later", timestamp=later, task="NEWER", open_loops=[{"text": "same loop", "status": "closed"}]), + ])) + result = runner.invoke(app, ["lead", "compile", "--root", str(ws), "--artifact", "input.json"]) + assert result.exit_code == 0, result.stdout + packet = json.loads((ws / ".sticky/current-state.json").read_text()) + assert packet["task"] == "NEWER" + assert packet["proof"]["run_id"] == "a-later" + assert packet["current_state"]["open_loops"] == [{"text": "same loop", "status": "closed"}] + assert "Close open loop" not in packet["current_state"]["next_clean_action"] + + +def test_lead_invalid_workspace_preserves_all_prior_outputs(ws: Path) -> None: + scaffold_workspace(ws) + (ws / "input.json").write_text(json.dumps(valid_lead_artifact())) + args = ["lead", "compile", "--root", str(ws), "--artifact", "input.json"] + assert runner.invoke(app, args).exit_code == 0 + before = {p: p.read_bytes() for p in (ws / ".sticky").rglob("*") if p.is_file()} + (ws / "AGENTS.md").unlink() + result = runner.invoke(app, args) + assert result.exit_code == 1 + assert "lead_workspace_validation_failed" in result.stdout + assert before == {p: p.read_bytes() for p in (ws / ".sticky").rglob("*") if p.is_file()} + + +def test_lead_preflights_receipt_directory_before_replacing_packet(ws: Path) -> None: + scaffold_workspace(ws) + (ws / "input.json").write_text(json.dumps(valid_lead_artifact())) + args = ["lead", "compile", "--root", str(ws), "--artifact", "input.json"] + assert runner.invoke(app, args).exit_code == 0 + before = [(ws / ".sticky" / name).read_bytes() for name in ["current-state.json", "current-state.md"]] + # Ordinary in-workspace invalid destination; no blocked skill-root probe. + shutil.rmtree(ws / ".sticky/receipts") + (ws / ".sticky/receipts").write_text("not a directory") + (ws / "input.json").write_text(json.dumps(valid_lead_artifact(task="changed"))) + assert runner.invoke(app, args).exit_code == 1 + assert before == [(ws / ".sticky" / name).read_bytes() for name in ["current-state.json", "current-state.md"]] + + +def test_lead_rolls_back_packet_if_receipt_commit_fails(ws: Path, monkeypatch: pytest.MonkeyPatch) -> None: + scaffold_workspace(ws) + (ws / "input.json").write_text(json.dumps(valid_lead_artifact())) + args = ["lead", "compile", "--root", str(ws), "--artifact", "input.json"] + assert runner.invoke(app, args).exit_code == 0 + before = {p: p.read_bytes() for p in (ws / ".sticky").rglob("*") if p.is_file()} + real_replace = Path.replace + def fail_receipt_commit(path: Path, target: Path) -> Path: + if Path(target).parent.name == "receipts": + raise OSError("synthetic disk failure") + return real_replace(path, target) + monkeypatch.setattr(Path, "replace", fail_receipt_commit) + (ws / "input.json").write_text(json.dumps(valid_lead_artifact(task="changed"))) + result = runner.invoke(app, args) + assert result.exit_code == 1 + assert "prior output files preserved" in result.stdout + assert before == {p: p.read_bytes() for p in (ws / ".sticky").rglob("*") if p.is_file()} + + +@pytest.mark.parametrize("nested", [False, True]) +def test_lead_synthetic_secret_keys_never_reach_diagnostics(ws: Path, nested: bool) -> None: + scaffold_workspace(ws) + sentinel = "sk-" + "A" * 30 # Intentionally fake, nonworking security sentinel. + data = valid_lead_artifact() + if nested: + data["claims"] = [{"claim": "ordinary", sentinel: "ordinary"}] + else: + data[sentinel] = "ordinary" + (ws / "input.json").write_text(json.dumps(data)) + result = runner.invoke(app, ["lead", "compile", "--root", str(ws), "--artifact", "input.json"]) + assert result.exit_code == 1 + assert "lead_artifact_secret_detected" in result.stdout + assert sentinel not in result.stdout + assert not (ws / ".sticky").exists() + + +@pytest.mark.parametrize("status", ["closed", "completed", "done", "resolved", "cancelled", "canceled", "CLOSED"]) +def test_lead_terminal_loops_not_recommended(ws: Path, status: str) -> None: + scaffold_workspace(ws) + (ws / "input.json").write_text(json.dumps(valid_lead_artifact(open_loops=[{"text": "finished", "status": status}, {"text": "work remains", "status": "open"}]))) + result = runner.invoke(app, ["lead", "compile", "--root", str(ws), "--artifact", "input.json"]) + assert result.exit_code == 0, result.stdout + packet = json.loads((ws / ".sticky/current-state.json").read_text()) + assert packet["current_state"]["next_clean_action"] == "Close open loop: work remains" + + +def test_lead_no_input_is_honest_zero_evidence(ws: Path) -> None: + scaffold_workspace(ws) + result = runner.invoke(app, ["lead", "compile", "--root", str(ws)]) + assert result.exit_code == 0, result.stdout + # Repeated compiles must not ingest their own receipts as phantom runs. + result = runner.invoke(app, ["lead", "compile", "--root", str(ws)]) + assert result.exit_code == 0 + packet = json.loads((ws / ".sticky/current-state.json").read_text()) + assert packet["artifacts_ingested"] == 0 + assert packet["artifacts"] == [] + assert packet["artifact_sources"] == [] + assert packet["proof"]["run_id"] is None + assert packet["proof"]["timestamp"] is None + assert packet["evidence_status"] == "no_evidence" + assert "Collect a tool-run artifact" in packet["current_state"]["next_clean_action"] + + +def test_lead_missing_chain_predecessor_is_not_consumed(ws: Path) -> None: + scaffold_workspace(ws) + (ws / "input.json").write_text(json.dumps(valid_lead_artifact())) + args = ["lead", "compile", "--root", str(ws), "--artifact", "input.json"] + assert runner.invoke(app, args).exit_code == 0 + assert runner.invoke(app, args).exit_code == 0 + first, second = sorted((ws / ".sticky/receipts").glob("*.jsonl")) + first.unlink() + assert runner.invoke(app, args).exit_code == 0 + receipt = json.loads(sorted((ws / ".sticky/receipts").glob("*.jsonl"))[-1].read_text()) + assert receipt["previous_receipt_hash"] is None + assert any("receipt_history_chain_mismatch" in warning for warning in receipt["validation_warnings"]) + assert receipt["trust"]["current_observations"] # Orphaned history must not suppress a real observation. + + +def _run_receipts(ws: Path) -> list[dict]: + return [json.loads(path.read_text()) for path in sorted((ws / "receipts").glob("*.jsonl"))] + + +def test_run_executes_real_context_hash_verification_without_shell(ws: Path) -> None: + scaffold_workspace(ws) + program = ws / "verify_context.py" + program.write_text("""import hashlib, json, os +from pathlib import Path +manifest = json.loads(Path(os.environ['AGENTMD_CONTEXT_FILE']).read_text()) +for entry in manifest['selected']: + assert hashlib.sha256(Path(entry['path']).read_bytes()).hexdigest() == entry['sha256'] +Path('verified.json').write_text(json.dumps({'selected': len(manifest['selected']), 'task': os.environ['AGENTMD_TASK']})) +print('context hashes verified') +""") + task = "verify context; do not interpret $(anything)" + args = ["run", "--root", str(ws), "--task", task, "--", sys.executable, str(program)] + result = runner.invoke(app, args) + assert result.exit_code == 0, result.stdout + verified = json.loads((ws / "verified.json").read_text()) + assert verified["selected"] > 0 + assert verified["task"] == task + pre, post = _run_receipts(ws) + assert pre["execution"]["status"] == "prepared" + assert post["execution"]["status"] == "completed" + assert post["execution"]["pre_receipt_hash"] == pre["receipt_hash"] + assert post["execution"]["output"]["stdout"]["sha256"] == hashlib.sha256(("context hashes verified" + os.linesep).encode()).hexdigest() + assert post["execution"]["context_manifest_sha256"] == hashlib.sha256((ws / ".agentmd/resolved-context.json").read_bytes()).hexdigest() + assert post["receipt_hash"] == _canonical_hash(post, "receipt_hash") + assert "verify_context.py" not in json.dumps(post) # Actual argv is not logged. + + +@pytest.mark.parametrize("kind,expected_code,expected_status", [ + ("failed", 7, "failed"), ("launch", 127, "launch_failed"), + ("timeout", 124, "timed_out"), ("output", 125, "output_limit_exceeded"), +]) +def test_run_real_process_terminal_outcomes(ws: Path, kind: str, expected_code: int, expected_status: str) -> None: + scaffold_workspace(ws) + programs = { + "failed": [sys.executable, "-c", "raise SystemExit(7)"], + "launch": [str(ws / "nonexistent-executable")], + "timeout": [sys.executable, "-c", "import time; time.sleep(20)"], + "output": [sys.executable, "-c", "import sys; sys.stdout.write('X' * 200000)"], + } + result = runner.invoke(app, ["run", "--root", str(ws), "--task", "real process outcome", "--timeout", "0.2", "--output-limit", "1024", "--", *programs[kind]]) + assert result.exit_code == expected_code, result.stdout + pre, post = _run_receipts(ws) + assert post["execution"]["status"] == expected_status + assert post["execution"]["launched"] is (kind != "launch") + assert post["receipt_hash"] == _canonical_hash(post, "receipt_hash") + if kind == "output": + assert post["execution"]["output"]["stdout"]["hashed_bytes"] == 1024 + assert post["execution"]["output"]["stdout"]["complete"] is False + + +def test_run_invalid_workspace_never_launches_child(ws: Path) -> None: + scaffold_workspace(ws) + (ws / "AGENTS.md").unlink() + result = runner.invoke(app, ["run", "--root", str(ws), "--task", "must not launch", "--", sys.executable, "-c", "from pathlib import Path; Path('launched').touch()"]) + assert result.exit_code == 1 + assert not (ws / "launched").exists() + assert _run_receipts(ws) == [] + + +def test_run_requires_explicit_command_not_guessed_adapter(ws: Path) -> None: + scaffold_workspace(ws) + result = runner.invoke(app, ["run", "--root", str(ws), "--task", "no guesses", "--adapter", "codex"]) + assert result.exit_code == 2 + assert _run_receipts(ws) == [] + + +def test_run_does_not_echo_or_record_synthetic_output_or_argv(ws: Path) -> None: + scaffold_workspace(ws) + sentinel = "sk-" + "A" * 30 # Fake security sentinel, never a live credential. + result = runner.invoke(app, ["run", "--root", str(ws), "--task", "privacy", "--", sys.executable, "-c", "import sys; print(sys.argv[1])", sentinel]) + assert result.exit_code == 0, result.stdout + assert sentinel not in result.stdout + assert all(sentinel not in json.dumps(receipt) for receipt in _run_receipts(ws)) + output = _run_receipts(ws)[-1]["execution"]["output"]["stdout"] + assert output["sha256"] == hashlib.sha256((sentinel + os.linesep).encode()).hexdigest() + + +def test_run_failure_stays_failed_when_compiled_into_lead_state(ws: Path) -> None: + scaffold_workspace(ws) + result = runner.invoke(app, ["run", "--root", str(ws), "--task", "actual failed work", "--", sys.executable, "-c", "raise SystemExit(7)"]) + assert result.exit_code == 7 + assert runner.invoke(app, ["lead", "compile", "--root", str(ws)]).exit_code == 0 + packet = json.loads((ws / ".sticky/current-state.json").read_text()) + assert packet["artifacts_ingested"] == 1 # Terminal receipt only, not prepared or compiler receipts. + assert packet["artifacts"][0]["verification_status"] == "contradicted" + assert packet["artifacts"][0]["execution"]["returncode"] == 7 + assert "Investigate local execution outcome: failed" in packet["current_state"]["next_clean_action"] + assert runner.invoke(app, ["lead", "compile", "--root", str(ws)]).exit_code == 0 + again = json.loads((ws / ".sticky/current-state.json").read_text()) + assert again["artifacts_ingested"] == 1 + assert again["deterministic_hash"] == packet["deterministic_hash"] + + +def test_run_output_limit_detected_before_silent_child_timeout(ws: Path) -> None: + scaffold_workspace(ws) + result = runner.invoke(app, ["run", "--root", str(ws), "--task", "bounded real output", "--timeout", "3", "--output-limit", "1024", "--", sys.executable, "-c", "import sys,time; sys.stdout.write('X'*1025); sys.stdout.flush(); time.sleep(20)"]) + assert result.exit_code == 125 + assert _run_receipts(ws)[-1]["execution"]["status"] == "output_limit_exceeded" + + +def test_run_reports_post_execution_workspace_failure(ws: Path) -> None: + scaffold_workspace(ws) + result = runner.invoke(app, ["run", "--root", str(ws), "--task", "local workspace change", "--", sys.executable, "-c", "from pathlib import Path; Path('AGENTS.md').unlink()"]) + assert result.exit_code == 1 + terminal = _run_receipts(ws)[-1] + assert terminal["execution"]["returncode"] == 0 + assert terminal["execution"]["workspace_validation_ok"] is False + assert terminal["validation"]["ok"] is False + + +def test_bundle_failed_rollback_preserves_recovery_bytes(ws: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from cli.agentmd import _atomic_write_bundle + old_path, second_path = ws / "old.txt", ws / "second.txt" + old_path.write_text("previous contents") + real_replace = Path.replace + def fail_commit_and_rollback(path: Path, target: Path) -> Path: + if target == second_path or path.suffix == ".bak": + raise OSError("synthetic unavailable destination") + return real_replace(path, target) + monkeypatch.setattr(Path, "replace", fail_commit_and_rollback) + with pytest.raises(ValueError, match="output_bundle_rollback_failed"): + _atomic_write_bundle(ws, [(old_path, "new contents"), (second_path, "second")]) + backups = list(ws.glob("*.bak")) + assert len(backups) == 1 + assert backups[0].read_text() == "previous contents" + + +def test_metadata_only_receipts_are_not_tool_run_evidence(ws: Path) -> None: + scaffold_workspace(ws) + assert runner.invoke(app, ["receipt", "--root", str(ws)]).exit_code == 0 + assert runner.invoke(app, ["lead", "compile", "--root", str(ws)]).exit_code == 0 + packet = json.loads((ws / ".sticky/current-state.json").read_text()) + assert packet["artifacts_ingested"] == 0 + assert packet["evidence_status"] == "no_evidence" + assert any("artifact_metadata_not_run_evidence" in warning for warning in packet["validation"]["warnings"]) + + +def test_lead_task_override_rejects_synthetic_secret_without_echo(ws: Path) -> None: + scaffold_workspace(ws) + sentinel = "sk-" + "A" * 30 + result = runner.invoke(app, ["lead", "compile", "--root", str(ws), "--task", sentinel]) + assert result.exit_code == 1 + assert sentinel not in result.stdout + assert not (ws / ".sticky").exists() + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX signal lifecycle; Windows needs native interruption coverage") +def test_run_interrupt_terminates_real_child_and_writes_terminal_receipt(ws: Path) -> None: + import signal + import subprocess + import time + scaffold_workspace(ws) + marker = ws / "child.pid" + command = [sys.executable, "-m", "cli.agentmd", "run", "--root", str(ws), "--task", "interrupt local work", "--timeout", "10", "--", sys.executable, "-c", "import os,time; from pathlib import Path; Path('child.pid').write_text(str(os.getpid())); time.sleep(30)"] + process = subprocess.Popen(command, cwd=REPO_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 5 + while not marker.exists() and time.monotonic() < deadline: + time.sleep(0.01) + assert marker.exists() + child_pid = int(marker.read_text()) + process.send_signal(signal.SIGINT) + stdout, stderr = process.communicate(timeout=10) + assert process.returncode == 130, (stdout, stderr) + terminal = _run_receipts(ws)[-1] + assert terminal["execution"]["status"] == "interrupted" + assert terminal["execution"]["launched"] is True + with pytest.raises(ProcessLookupError): + os.kill(child_pid, 0) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + + +def test_declared_discovered_artifact_still_requires_complete_schema(ws: Path) -> None: + scaffold_workspace(ws) + (ws / "receipts/incomplete.jsonl").write_text(json.dumps({"schema_version": "lead-artifact.v1"}) + "\n") + result = runner.invoke(app, ["lead", "compile", "--root", str(ws)]) + assert result.exit_code == 1 + assert "lead_artifact_schema_invalid" in result.stdout + assert not (ws / ".sticky").exists() + + +def test_unhashed_execution_shaped_record_is_not_run_evidence(ws: Path) -> None: + scaffold_workspace(ws) + (ws / "receipts/incomplete.jsonl").write_text(json.dumps({"execution": {}}) + "\n") + result = runner.invoke(app, ["lead", "compile", "--root", str(ws)]) + assert result.exit_code == 1 + assert "lead_execution_receipt_invalid" in result.stdout + assert not (ws / ".sticky").exists() + + +def test_run_nonfinite_timeout_rejected_before_any_launch(ws: Path) -> None: + scaffold_workspace(ws) + result = runner.invoke(app, ["run", "--root", str(ws), "--task", "bounded run", "--timeout", "nan", "--", sys.executable, "-c", "from pathlib import Path; Path('launched').touch()"]) + assert result.exit_code == 2 + assert not (ws / "launched").exists() + assert _run_receipts(ws) == [] + assert not (ws / ".agentmd").exists() From ee1fb8cad051f729e9ef64adf593c154d9ed0184 Mon Sep 17 00:00:00 2001 From: AgentMD local repair Date: Sat, 24 Oct 2026 13:15:33 -0500 Subject: [PATCH 5/5] fix: validate real local embeddings and durable memory storage --- PROJECT_STATUS.md | 4 +- README.md | 3 +- RELEASE_CHECKLIST.md | 2 +- apps/api/requirements.txt | 5 +- apps/api/src/memory/cli.py | 2 +- apps/api/src/memory/embedding_contract.py | 18 + apps/api/src/memory/memory_engine.py | 826 +++++++++------------- apps/api/src/memory/models.py | 29 +- apps/api/src/memory/validation.py | 25 +- apps/api/tests/test_memory_vectors.py | 319 --------- docs/adoption_quickstart.md | 2 +- docs/embedding_validation.md | 45 ++ docs/proof_note.md | 2 +- docs/security_hardening_todo.md | 8 +- tests/memory/test_memory_vectors.py | 279 ++++++++ 15 files changed, 716 insertions(+), 853 deletions(-) create mode 100644 apps/api/src/memory/embedding_contract.py delete mode 100644 apps/api/tests/test_memory_vectors.py create mode 100644 docs/embedding_validation.md create mode 100644 tests/memory/test_memory_vectors.py diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index c76c04b..19327e6 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -19,8 +19,8 @@ Acceptance is correct results on real local work, rejection without inconsistent ## Remaining blockers and non-goals -- Legacy API dependencies are not a supported production runtime: sentence-transformers requires a separately validated migration; the coupled OpenTelemetry/Firestore update passed offline dependency/client/serialization checks. Its direct audit gate remains failing, not bypassed. -- No real model-provider calls, provider credentials, hosted auth/account actions or cloud deployments were run. +- Sentence Transformers 6.0.0 and the isolated memory engine passed actual offline model/storage validation, including persistence/fallback/backup and model contract checks; the coupled OpenTelemetry/Firestore update passed offline dependency/client/serialization checks. These are scoped runtime checks, not full production API assurance. +- Real local MiniLM CPU inference was run using an approved pinned safetensors snapshot. No paid model-provider calls, provider credentials, hosted auth/account actions or cloud deployments were run. - Skill edits verify supplied score/receipt/hash consistency, not the evaluator's truth or actual improvement; no optimizer/benchmark engine is implemented. - Imported claims remain source assertions; hashes are not authentication or truth verification. - No signature chain, external notarization, production-SaaS assurance or multi-user execution isolation. diff --git a/README.md b/README.md index d8fed70..b06438d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Schema validation, deterministic hashes and receipts establish local integrity - `run` now executes caller-specified argv locally; provider-specific integrations are not implemented or verified. - Example provider-named artifacts are fixtures, not evidence of calls to Codex, Claude or Gemini. - Source identity is asserted, not authenticated; `what_is_true` means source-reported verified claims, never an authorization score. -- The legacy API dependency gate remains blocked; do not release on the basis of a passing CLI suite alone. +- The isolated memory engine now has real offline MiniLM/Chroma/SQLite validation; see `docs/embedding_validation.md`. This does not establish full legacy API/production readiness. ## Trust & adoption docs @@ -29,6 +29,7 @@ Schema validation, deterministic hashes and receipts establish local integrity - `docs/determinism.md` - `docs/receipt_integrity.md` - `docs/threat_model.md` +- `docs/embedding_validation.md` - `SECURITY.md` ## Problem diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 9765235..bd9ed6e 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -8,7 +8,7 @@ Current published baseline: `v0.3.0-alpha.3` (`6602fc4874c1824faa4d2fd23d3ec560f - [ ] All CLI regressions pass **and** real explicit-command outcomes are verified. - [ ] Run `agentmd run --task "verify this checkout" -- python scripts/verify_local.py`; compile the generated artifact, not just canned provider fixtures. - [ ] Independently verify source hashes, final packet hash, receipts/chain, repeat determinism and consistent failure outputs. -- [ ] Verify current CLI and API dependency audits; resolve the documented sentence-transformers migration blocker and verify the coordinated Google/telemetry update. Do not bypass them. +- [ ] Verify current CLI/API dependency audits, the isolated embedding/storage path and coordinated Google/telemetry update. Do not bypass gates or infer full production API readiness. - [ ] Native Windows/Python 3.11 runner/wrapper proof and current exact-commit CI. - [ ] Review account governance/credentials separately; source cleanup is not revocation proof. - [ ] Obtain authorization for publication, then verify exact remote commit and CI before tagging. No tag/version is prescribed by this checklist. diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 7be9711..39e0efa 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -8,10 +8,13 @@ starlette==1.7.0 anthropic==0.49.0 openai==1.30.0 google-cloud-aiplatform==1.133.0 -sentence-transformers==2.2.2 # Dedicated embedding model +sentence-transformers==6.0.0 # Enforces trust_remote_code=False for local custom modules +torch==2.14.1 # CPU +cpu build tested; see docs/embedding_validation.md +numpy==1.26.4 # Compatible with the retained Chroma 0.4 native stack # Vector Storage & Memory chromadb==0.4.15 +posthog==5.4.0 # Compatible Chroma telemetry API; telemetry disabled by engine duckdb==1.5.6 clickhouse-connect==0.6.20 SQLAlchemy==2.0.23 # For SQLite fallback diff --git a/apps/api/src/memory/cli.py b/apps/api/src/memory/cli.py index 8adb1c8..60ae997 100644 --- a/apps/api/src/memory/cli.py +++ b/apps/api/src/memory/cli.py @@ -17,7 +17,7 @@ from rich.table import Table from rich.progress import Progress, SpinnerColumn from .memory_engine import MemoryEngine -from ..exceptions import MemoryException +from .exceptions import MemoryException # Configure logging logger = structlog.get_logger("sticky.memory.cli") diff --git a/apps/api/src/memory/embedding_contract.py b/apps/api/src/memory/embedding_contract.py new file mode 100644 index 0000000..97562e4 --- /dev/null +++ b/apps/api/src/memory/embedding_contract.py @@ -0,0 +1,18 @@ +"""Shared vector shape contract; storage additionally checks the loaded model.""" +from __future__ import annotations + +import math +from typing import Sequence + +DEFAULT_EMBEDDING_DIMENSION = 384 # sentence-transformers/all-MiniLM-L6-v2 +MAX_EMBEDDING_DIMENSION = 65536 + + +def validate_vector(values: Sequence[float], dimension: int) -> list[float]: + if not 0 < dimension <= MAX_EMBEDDING_DIMENSION or len(values) != dimension: + raise ValueError(f"Embedding must have {dimension} dimensions") + result = [float(value) for value in values] + # Both Chroma and the SQLite BLOB contract store IEEE-754 float32. + if not all(math.isfinite(value) and abs(value) <= 3.4028234663852886e38 for value in result): + raise ValueError("Embedding must contain only finite float32-representable numbers") + return result diff --git a/apps/api/src/memory/memory_engine.py b/apps/api/src/memory/memory_engine.py index 2f9174a..869dd0c 100644 --- a/apps/api/src/memory/memory_engine.py +++ b/apps/api/src/memory/memory_engine.py @@ -1,534 +1,388 @@ +"""Local Sentence Transformers embeddings with persistent Chroma/SQLite storage. + +No provider, Firebase or cloud service is required. Model files must already be +present unless the caller explicitly opts into a model download. Existing stores +without a matching model contract are rejected, never silently re-embedded/reset. """ -Memory Engine with Vector Storage and Persistence -Created: 2025-11-02 19:46:15 -Author: electricwolfemarshmallowhypertext -""" +from __future__ import annotations -import os +from datetime import datetime, timezone +import hashlib +from importlib.metadata import version +import json from pathlib import Path import shutil -import logging -from typing import Optional, Dict, List, Any +import sqlite3 +import tempfile +from typing import Any + import chromadb from chromadb.config import Settings -from chromadb.utils import embedding_functions -import sqlite3 -from datetime import datetime, timedelta -import json -import zstandard as zstd -import xxhash -from sentence_transformers import SentenceTransformer import numpy as np -from prometheus_client import Counter, Histogram, Gauge +from prometheus_client import Counter, Gauge, Histogram +from sentence_transformers import SentenceTransformer import structlog -from ..exceptions import MemoryException +import torch +import zstandard as zstd + +from .embedding_contract import validate_vector +from .exceptions import MemoryException -# Configure logging logger = structlog.get_logger("sticky.memory.engine") +MEMORY_SIZE = Gauge("sticky_memory_size_bytes", "Memory store size in bytes", ["store"]) +MEMORY_OPS = Counter("sticky_memory_operations_total", "Memory operations count", ["operation"]) +MEMORY_OP_DURATION = Histogram("sticky_memory_operation_duration_seconds", "Memory operation duration") +EMBEDDING_DURATION = Histogram("sticky_embedding_generation_duration_seconds", "Embedding generation duration") +CACHE_HITS = Counter("sticky_memory_cache_hits_total", "Memory cache hit count") + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _loaded_model_fingerprint(model: SentenceTransformer) -> str: + """Bind actual loaded parameters and tokenizer/pooling semantics, not a label.""" + modules = [] + for name, module in model.named_children(): + config = dict(module.get_config_dict()) if hasattr(module, "get_config_dict") else {} + if hasattr(module, "auto_model"): + transformer = module.auto_model.config.to_dict() + for incidental in ("_name_or_path", "transformers_version"): + transformer.pop(incidental, None) + config["transformer"] = transformer + modules.append({"name": name, "type": f"{type(module).__module__}.{type(module).__name__}", "config": config}) + tokenizer = model.tokenizer + tokenizer_state = (tokenizer.backend_tokenizer.to_str() if hasattr(tokenizer, "backend_tokenizer") + else {"vocab": tokenizer.get_vocab(), "special_tokens": tokenizer.special_tokens_map}) + semantic_config = {"modules": modules, "tokenizer": tokenizer_state, "normalize_embeddings": True, + "prompts": getattr(model, "prompts", {}), + "default_prompt_name": getattr(model, "default_prompt_name", None), + "truncate_dim": getattr(model, "truncate_dim", None), + "dimension": model.get_embedding_dimension(), + "libraries": {name: version(name) for name in ("sentence-transformers", "transformers", "torch")}} + digest = hashlib.sha256(json.dumps(semantic_config, sort_keys=True, separators=(",", ":"), default=str).encode()) + for name, tensor in sorted(model.state_dict().items()): + digest.update(json.dumps([name, str(tensor.dtype), list(tensor.shape)], separators=(",", ":")).encode()) + digest.update(tensor.detach().cpu().contiguous().reshape(-1).view(torch.uint8).numpy().tobytes()) + return digest.hexdigest() -# Metrics -MEMORY_SIZE = Gauge( - "sticky_memory_size_bytes", - "Memory store size in bytes", - ["store"] -) -MEMORY_OPS = Counter( - "sticky_memory_operations_total", - "Memory operations count", - ["operation"] -) -MEMORY_OP_DURATION = Histogram( - "sticky_memory_operation_duration_seconds", - "Memory operation duration" -) -EMBEDDING_DURATION = Histogram( - "sticky_embedding_generation_duration_seconds", - "Embedding generation duration" -) -CACHE_HITS = Counter( - "sticky_memory_cache_hits_total", - "Memory cache hit count" -) class MemoryEngine: - """Memory engine with vector storage and persistence""" - + """Single-writer local vector store; synchronous model work inside async API.""" + def __init__( self, persist_dir: str = "memory_store", - sqlite_fallback_path: str = "memory_store/fallback.db", + sqlite_fallback_path: str | None = None, max_size_mb: int = 5, - backup_dir: str = "memory_store/backups", + backup_dir: str | None = None, env: str = "production", - embedding_model: str = "all-MiniLM-L6-v2", + embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2", embedding_cache_size: int = 1000, - embedding_batch_size: int = 32 + embedding_batch_size: int = 32, + embedding_revision: str | None = None, + embedding_model_id: str | None = None, + local_files_only: bool = True, ): - self.persist_dir = Path(persist_dir) - self.sqlite_path = Path(sqlite_fallback_path) - self.backup_dir = Path(backup_dir) + self.persist_dir = Path(persist_dir).resolve() + self.sqlite_path = Path(sqlite_fallback_path).resolve() if sqlite_fallback_path else self.persist_dir / "fallback.db" + self.backup_dir = Path(backup_dir).resolve() if backup_dir else self.persist_dir.with_name(self.persist_dir.name + "-backups") + if (self.backup_dir == self.persist_dir or self.persist_dir in self.backup_dir.parents + or self.backup_dir in self.persist_dir.parents or self.sqlite_path == self.backup_dir + or self.backup_dir in self.sqlite_path.parents or self.sqlite_path in self.backup_dir.parents): + raise ValueError("Backup directory must not overlap live Chroma or SQLite storage") + if max_size_mb <= 0 or embedding_cache_size < 0 or embedding_batch_size <= 0: + raise ValueError("Invalid storage/cache/batch limits") self.max_size_bytes = max_size_mb * 1024 * 1024 self.env = env - - # Create directories - self.persist_dir.mkdir(exist_ok=True, parents=True) - self.backup_dir.mkdir(exist_ok=True, parents=True) - self.sqlite_path.parent.mkdir(exist_ok=True, parents=True) - - # Initialize components self.client = None - self.sqlite_conn = None - self.embedding_model = SentenceTransformer(embedding_model) - self.embedding_cache = {} + self.collections: dict[str, Any] = {} + self.sqlite_conn: sqlite3.Connection | None = None + self.initialized = False + self.storage_degraded = False + self.embedding_model = SentenceTransformer( + embedding_model, revision=embedding_revision, device="cpu", + trust_remote_code=False, local_files_only=local_files_only, + model_kwargs={"use_safetensors": True}, + ) + dimension = self.embedding_model.get_embedding_dimension() + if not isinstance(dimension, int) or dimension <= 0: + raise ValueError("Loaded model has no fixed embedding dimension") + self.embedding_dimension = dimension + self.embedding_identity = embedding_model_id or f"{embedding_model}@{embedding_revision or 'local'}" + self.embedding_fingerprint = _loaded_model_fingerprint(self.embedding_model) + self.embedding_cache: dict[str, list[float]] = {} self.embedding_cache_size = embedding_cache_size self.embedding_batch_size = embedding_batch_size - - # Initialize compression self.compressor = zstd.ZstdCompressor(level=3) - - # Track initialization - self.initialized = False - + + @property + def _contract(self) -> dict[str, Any]: + return {"embedding_dimension": self.embedding_dimension, "embedding_model": self.embedding_identity, + "embedding_fingerprint": self.embedding_fingerprint} + async def initialize(self) -> None: - """Initialize vector store with fallback and monitoring""" + if self.initialized: + return try: - # Setup embedding function - sentence_transformer_ef = embedding_functions.SentenceTransformerEmbeddingFunction( - model_name="all-MiniLM-L6-v2" + self.persist_dir.mkdir(parents=True, exist_ok=True) + self.sqlite_path.parent.mkdir(parents=True, exist_ok=True) + self.client = chromadb.PersistentClient( + path=str(self.persist_dir), + settings=Settings(anonymized_telemetry=False, allow_reset=False), ) - - # Initialize Chroma - self.client = chromadb.Client(Settings( - persist_directory=str(self.persist_dir), - chroma_db_impl="duckdb+parquet", - anonymized_telemetry=False, - allow_reset=self.env == "development" - )) - - # Setup collections with proper schemas - await self._setup_collections(sentence_transformer_ef) - - # Initialize SQLite fallback + await self._setup_collections() await self._setup_sqlite_fallback() - - # Verify size limits await self._check_storage_limits() - - # Initial backup await self._create_backup() - self.initialized = True - - except Exception as e: - logger.error("Failed to initialize memory engine", error=str(e)) - raise MemoryException("Memory initialization failed") from e - - async def _setup_collections(self, embedding_fn: Any) -> None: - """Setup required collections with proper configuration""" - collections = { - "user_memories": { - "metadata": {"hnsw:space": "cosine", "hnsw:M": 16}, - "embedding_function": embedding_fn - }, - "persona_contexts": { - "metadata": {"hnsw:space": "cosine", "hnsw:M": 8}, - "embedding_function": embedding_fn - } - } - - for name, config in collections.items(): - try: - self.client.create_collection( - name=name, - metadata=config["metadata"], - embedding_function=config["embedding_function"] - ) - except ValueError: # Collection exists - continue - + except Exception as error: + self.cleanup() + raise MemoryException("Memory initialization failed; inspect store/model compatibility") from error + + async def _setup_collections(self) -> None: + existing = {collection.name for collection in self.client.list_collections()} + for name, connections in (("user_memories", 16), ("persona_contexts", 8)): + metadata = {"hnsw:space": "cosine", "hnsw:M": connections, **self._contract} + if name in existing: + collection = self.client.get_collection(name, embedding_function=None) + current = collection.metadata or {} + if any(current.get(key) != value for key, value in self._contract.items()): + # Even an empty existing store needs an explicit migration if it + # declares another model. Never overwrite model identity silently. + if collection.count() or any(key in current for key in self._contract): + raise ValueError("Existing collection model/dimension mismatch; migrate a copy explicitly") + collection.modify(metadata=metadata) + else: + collection = self.client.create_collection(name, metadata=metadata, embedding_function=None) + self.collections[name] = collection + async def _setup_sqlite_fallback(self) -> None: - """Initialize SQLite fallback database""" self.sqlite_conn = sqlite3.connect(self.sqlite_path) - cursor = self.sqlite_conn.cursor() - - # Create tables for fallback storage - cursor.execute(""" - CREATE TABLE IF NOT EXISTS memories ( - id TEXT PRIMARY KEY, - user_id TEXT, - persona_id TEXT, - content TEXT, - embedding BLOB, - created_at TIMESTAMP, - metadata TEXT - ) - """) - - cursor.execute(""" - CREATE INDEX IF NOT EXISTS idx_user_persona - ON memories(user_id, persona_id) - """) - + tables = {row[0] for row in self.sqlite_conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + if "memories" in tables and "engine_metadata" not in tables: + if self.sqlite_conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]: + raise ValueError("Existing fallback data has no model contract; migrate a copy explicitly") + self.sqlite_conn.execute("CREATE TABLE IF NOT EXISTS engine_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + expected = json.dumps(self._contract, sort_keys=True) + row = self.sqlite_conn.execute("SELECT value FROM engine_metadata WHERE key='embedding_contract'").fetchone() + if row and row[0] != expected: + raise ValueError("SQLite embedding model/dimension mismatch") + if not row and "memories" in tables and self.sqlite_conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]: + raise ValueError("Existing fallback data has no model contract; migrate a copy explicitly") + self.sqlite_conn.execute("INSERT OR IGNORE INTO engine_metadata VALUES ('embedding_contract', ?)", (expected,)) + self.sqlite_conn.execute("""CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, user_id TEXT, persona_id TEXT, content BLOB, + embedding BLOB, created_at TEXT, metadata TEXT)""") + self.sqlite_conn.execute("CREATE INDEX IF NOT EXISTS idx_user_persona ON memories(user_id, persona_id)") self.sqlite_conn.commit() - - async def build_context( - self, - user_id: str, - thread_id: str, - persona_id: str, - query: str, - include_memory: bool = True, - top_k: int = 3 - ) -> Dict: - """Build conversation context with vector similarity search""" - try: - # Get session messages - session = await self.load_session(user_id, thread_id) - - memories = [] - if include_memory: - # Get query embedding - query_embedding = await self._get_embedding(query) - - # Search vector store - with MEMORY_OP_DURATION.time(): - results = self.client.collection("user_memories").query( - query_embeddings=[query_embedding], - where={ - "$and": [ - {"user_id": user_id}, - {"persona_id": persona_id} - ] - }, - n_results=top_k - ) - MEMORY_OPS.labels("search").inc() - - memories = [ - json.loads(meta["memory"]) - for meta in results["metadatas"][0] - ] - - return { - "messages": session["messages"], - "memory": self._format_memories(memories) - } - - except Exception as e: - logger.error("Failed to build context", error=str(e)) - raise MemoryException("Context building failed") from e - - async def transfer_context( - self, - user_id: str, - from_persona: str, - to_persona: str, - query: str - ) -> List[Dict]: - """Transfer relevant memories between personas""" + + def _require_initialized(self) -> None: + if not self.initialized or self.sqlite_conn is None: + raise MemoryException("Memory engine is not initialized") + + async def _get_embedding(self, text: str) -> list[float]: + if not isinstance(text, str) or not text.strip(): + raise MemoryException("Embedding input must be nonempty text") + key = hashlib.sha256(text.encode()).hexdigest() + if key in self.embedding_cache: + CACHE_HITS.inc() + return list(self.embedding_cache[key]) try: - # Get query embedding - query_embedding = await self._get_embedding(query) - - # Search source persona memories - with MEMORY_OP_DURATION.time(): - results = self.client.collection("user_memories").query( - query_embeddings=[query_embedding], - where={ - "$and": [ - {"user_id": user_id}, - {"persona_id": from_persona} - ] - }, - n_results=5 - ) - MEMORY_OPS.labels("search").inc() - - # Transfer relevant memories - transferred = [] - for i, memory in enumerate(results["metadatas"][0]): - memory_data = json.loads(memory["memory"]) - embedding = results["embeddings"][0][i] - - # Store for new persona - event = await self.store_memory( - user_id=user_id, - thread_id=memory_data["thread_id"], - persona_id=to_persona, - text=memory_data["text"], - type="transferred", - salience=memory_data["salience"], - embedding=embedding + with EMBEDDING_DURATION.time(): + vector = self.embedding_model.encode( + text, batch_size=self.embedding_batch_size, convert_to_numpy=True, + normalize_embeddings=True, show_progress_bar=False, ) - - transferred.append(event) - - return transferred - - except Exception as e: - logger.error("Failed to transfer context", error=str(e)) - raise MemoryException("Context transfer failed") from e - - async def store_memory( - self, - user_id: str, - thread_id: str, - persona_id: str, - text: str, - type: str, - salience: float, - embedding: Optional[List[float]] = None - ) -> Dict: - """Store vectorized memory with compression""" + result = validate_vector(vector.tolist(), self.embedding_dimension) + if self.embedding_cache_size: + self.embedding_cache[key] = result + while len(self.embedding_cache) > self.embedding_cache_size: + self.embedding_cache.pop(next(iter(self.embedding_cache))) + return list(result) + except Exception as error: + raise MemoryException("Embedding generation failed") from error + + async def store_memory(self, user_id: str, thread_id: str, persona_id: str, text: str, + type: str, salience: float, embedding: list[float] | None = None) -> dict: + self._require_initialized() try: - # Validate inputs - if not user_id or not thread_id or not text: + if not all(isinstance(value, str) and value.strip() for value in (user_id, thread_id, persona_id, text, type)): raise ValueError("Missing required fields") - - # Generate memory ID - memory_id = xxhash.xxh64( - f"{user_id}:{thread_id}:{text}".encode() - ).hexdigest() - - # Get embedding if not provided - if embedding is None: - embedding = await self._get_embedding(text) - - # Validate embedding - if len(embedding) != 1024: - raise ValueError("Invalid embedding dimension") - - # Create memory event - event = { - "id": memory_id, - "text": text, - "type": type, - "salience": salience, - "created_at": datetime.utcnow().isoformat(), - "thread_id": thread_id, - "user_id": user_id, - "persona_id": persona_id - } - - # Compress text - compressed_text = self.compressor.compress(text.encode()) - + if len(text) > 4096 or not np.isfinite(salience) or not 0 <= salience <= 1: + raise ValueError("Invalid text size or salience") + vector = validate_vector(embedding if embedding is not None else await self._get_embedding(text), self.embedding_dimension) + material = json.dumps([user_id, thread_id, persona_id, text], separators=(",", ":")).encode() + memory_id = hashlib.sha256(material).hexdigest() + event = {"id": memory_id, "text": text, "type": type, "salience": salience, + "created_at": _now(), "thread_id": thread_id, "user_id": user_id, "persona_id": persona_id} + await self._check_storage_limits(additional_bytes=len(text.encode()) + len(vector) * 4 + len(json.dumps(event).encode())) try: - # Store in vector database with MEMORY_OP_DURATION.time(): - self.client.collection("user_memories").add( - ids=[memory_id], - embeddings=[embedding], - metadatas=[{ - "user_id": user_id, - "persona_id": persona_id, - "memory": json.dumps(event) - }], - documents=[compressed_text] + self.collections["user_memories"].upsert( + ids=[memory_id], embeddings=[vector], documents=[text], + metadatas=[{"user_id": user_id, "thread_id": thread_id, "persona_id": persona_id, + "memory": json.dumps(event)}], ) - MEMORY_OPS.labels("store").inc() - - except Exception as e: - # Fallback to SQLite - logger.warning("Vector store failed, using SQLite fallback", error=str(e)) - cursor = self.sqlite_conn.cursor() - cursor.execute(""" - INSERT INTO memories (id, user_id, persona_id, content, embedding, created_at, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - memory_id, - user_id, - persona_id, - compressed_text, - np.array(embedding).tobytes(), - event["created_at"], - json.dumps(event) - )) + self.sqlite_conn.execute("DELETE FROM memories WHERE id=?", (memory_id,)) self.sqlite_conn.commit() - - # Check size limits - await self._check_storage_limits() - - return event - - except Exception as e: - logger.error("Failed to store memory", error=str(e)) - raise MemoryException("Memory storage failed") from e - - async def load_session( - self, - user_id: str, - thread_id: str - ) -> Dict: - """Load conversation session""" + backend = "chroma" + except Exception as error: + self.storage_degraded = True + logger.warning("Vector write failed; persisting queryable SQLite fallback", error_type=type_name(error)) + self.sqlite_conn.execute("""INSERT OR REPLACE INTO memories + (id,user_id,persona_id,content,embedding,created_at,metadata) VALUES (?,?,?,?,?,?,?)""", + (memory_id, user_id, persona_id, self.compressor.compress(text.encode()), + np.asarray(vector, dtype=np.float32).tobytes(), event["created_at"], json.dumps(event))) + self.sqlite_conn.commit() + backend = "sqlite" + MEMORY_OPS.labels("store").inc() + return {**event, "storage_backend": backend} + except Exception as error: + raise MemoryException("Memory storage failed") from error + + def _fallback_rows(self, user_id: str, persona_id: str | None = None) -> list[tuple[dict, list[float]]]: + sql, params = "SELECT metadata,embedding FROM memories WHERE user_id=?", [user_id] + if persona_id is not None: + sql += " AND persona_id=?" + params.append(persona_id) + return [(json.loads(meta), validate_vector(np.frombuffer(vector, dtype=np.float32).tolist(), self.embedding_dimension)) + for meta, vector in self.sqlite_conn.execute(sql, params)] + + async def load_session(self, user_id: str, thread_id: str) -> dict: + self._require_initialized() + if not user_id or not thread_id: + raise MemoryException("Missing user_id or thread_id") + memories: dict[str, dict] = {} try: - if not user_id or not thread_id: - raise ValueError("Missing user_id or thread_id") - - with MEMORY_OP_DURATION.time(): - collection = self.client.collection("user_memories") - results = collection.get( - where={ - "$and": [ - {"user_id": user_id}, - {"thread_id": thread_id} - ] - }, - limit=100 - ) - MEMORY_OPS.labels("retrieve").inc() - - if not results["ids"]: - return { - "thread_id": thread_id, - "user_id": user_id, - "messages": [], - "created_at": datetime.utcnow().isoformat() - } - - # Sort by timestamp - memories = [] - for meta in results["metadatas"]: - memory = json.loads(meta["memory"]) - memories.append(memory) - - memories.sort(key=lambda x: x["created_at"]) - - return { - "thread_id": thread_id, - "user_id": user_id, - "messages": memories, - "created_at": memories[0]["created_at"] - } - - except Exception as e: - logger.error("Failed to load session", error=str(e)) - raise MemoryException("Session loading failed") from e - - async def _get_embedding(self, text: str) -> List[float]: - """Generate vector embedding with caching""" + records = self.collections["user_memories"].get( + where={"$and": [{"user_id": user_id}, {"thread_id": thread_id}]}, include=["metadatas"]) + for meta in records["metadatas"] or []: + event = json.loads(meta["memory"]) + memories[event["id"]] = event + except Exception as error: + self.storage_degraded = True + logger.warning("Vector read failed; returning available SQLite fallback", error_type=type_name(error)) + for event, _ in self._fallback_rows(user_id): + if event["thread_id"] == thread_id: + memories[event["id"]] = event + ordered = sorted(memories.values(), key=lambda event: (event["created_at"], event["id"])) + MEMORY_OPS.labels("retrieve").inc() + return {"thread_id": thread_id, "user_id": user_id, "messages": ordered, + "created_at": ordered[0]["created_at"] if ordered else _now(), "storage_degraded": self.storage_degraded} + + async def _query(self, user_id: str, persona_id: str, query: str, top_k: int) -> list[tuple[dict, list[float]]]: + self._require_initialized() + if not user_id or not persona_id or not isinstance(top_k, int) or not 1 <= top_k <= 100: + raise MemoryException("Invalid query scope or top_k") + vector = await self._get_embedding(query) + candidates: dict[str, tuple[float, dict, list[float]]] = {} try: - if not text: - raise ValueError("Empty text") - - # Check cache - cache_key = xxhash.xxh64(text.encode()).hexdigest() - if cache_key in self.embedding_cache: - CACHE_HITS.inc() - return self.embedding_cache[cache_key] - - # Generate embedding - with EMBEDDING_DURATION.time(): - embedding = self.embedding_model.encode( - text, - batch_size=self.embedding_batch_size - ).tolist() - - # Update cache - self.embedding_cache[cache_key] = embedding - if len(self.embedding_cache) > self.embedding_cache_size: - # Remove oldest entry - self.embedding_cache.pop(next(iter(self.embedding_cache))) - - return embedding - - except Exception as e: - logger.error("Failed to generate embedding", error=str(e)) - raise MemoryException("Embedding generation failed") from e - - async def _check_storage_limits(self) -> None: - """Verify and enforce storage limits""" - total_size = 0 - - # Check Chroma size - for path in self.persist_dir.rglob('*'): - if path.is_file(): - total_size += path.stat().st_size - - # Check SQLite size - if self.sqlite_path.exists(): - total_size += self.sqlite_path.stat().st_size - - # Update metrics - MEMORY_SIZE.labels("total").set(total_size) - - if total_size > self.max_size_bytes: - logger.warning( - "Storage size exceeds limit", - current_size=total_size, - limit=self.max_size_bytes - ) - await self._cleanup_old_data() - + collection = self.collections["user_memories"] + if collection.count(): + results = collection.query(query_embeddings=[vector], + where={"$and": [{"user_id": user_id}, {"persona_id": persona_id}]}, + n_results=min(top_k, collection.count()), include=["metadatas", "embeddings", "distances"]) + for meta, embedding, distance in zip(results["metadatas"][0], results["embeddings"][0], results["distances"][0]): + event = json.loads(meta["memory"]) + candidates[event["id"]] = (float(distance), event, list(embedding)) + except Exception as error: + self.storage_degraded = True + logger.warning("Vector search failed; querying SQLite fallback", error_type=type_name(error)) + query_vector = np.asarray(vector) + for event, embedding in self._fallback_rows(user_id, persona_id): + stored = np.asarray(embedding) + norm = float(np.linalg.norm(query_vector) * np.linalg.norm(stored)) + distance = 1 - float(np.dot(query_vector, stored)) / norm if norm else 1.0 + candidates[event["id"]] = (distance, event, embedding) + MEMORY_OPS.labels("search").inc() + ordered = sorted(candidates.values(), key=lambda item: (item[0], item[1]["id"]))[:top_k] + return [(event, embedding) for _, event, embedding in ordered] + + async def build_context(self, user_id: str, thread_id: str, persona_id: str, query: str, + include_memory: bool = True, top_k: int = 3) -> dict: + session = await self.load_session(user_id, thread_id) + matches = await self._query(user_id, persona_id, query, top_k) if include_memory else [] + return {"messages": session["messages"], "memory": self._format_memories([event for event, _ in matches]), + "storage_degraded": self.storage_degraded} + + async def transfer_context(self, user_id: str, from_persona: str, to_persona: str, query: str) -> list[dict]: + if not to_persona or to_persona == from_persona: + raise MemoryException("Transfer requires different source and destination personas") + matches = await self._query(user_id, from_persona, query, 5) + return [await self.store_memory(user_id, event["thread_id"], to_persona, event["text"], + "transferred", event["salience"], embedding) + for event, embedding in matches] + + async def _check_storage_limits(self, additional_bytes: int = 0) -> None: + paths = {path.resolve() for path in self.persist_dir.rglob("*") if path.is_file()} + if self.sqlite_path.is_file(): + paths.add(self.sqlite_path) + total = sum(path.stat().st_size for path in paths) + MEMORY_SIZE.labels("total").set(total) + if total + additional_bytes > self.max_size_bytes: + raise MemoryException("Memory size exceeds configured limit; archive data explicitly") + async def _cleanup_old_data(self) -> None: - """Remove oldest data to maintain size limits""" - try: - # Get all collections - for collection in self.client.list_collections(): - # Get count and sort by timestamp - results = collection.get( - limit=1000, - where={}, - include=["metadatas"] - ) - - if results["ids"]: - # Sort by timestamp and remove oldest - sorted_results = sorted( - zip(results["ids"], results["metadatas"]), - key=lambda x: json.loads(x[1]["memory"])["created_at"] - ) - - # Remove oldest 20% - to_remove = sorted_results[:len(sorted_results)//5] - collection.delete(ids=[id for id, _ in to_remove]) - - # Vacuum SQLite - if self.sqlite_conn: - self.sqlite_conn.execute("VACUUM") - - except Exception as e: - logger.error("Failed to cleanup old data", error=str(e)) - - async def _create_backup(self) -> None: - """Create timestamped backup""" - timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") - backup_path = self.backup_dir / f"memory_store_{timestamp}" - - try: - # Backup Chroma - shutil.copytree(self.persist_dir, backup_path) - - # Backup SQLite - if self.sqlite_path.exists(): - shutil.copy2( - self.sqlite_path, - backup_path / "fallback.db" - ) - - # Cleanup old backups (keep last 5) - backups = sorted(self.backup_dir.glob("memory_store_*")) - for backup in backups[:-5]: - shutil.rmtree(backup) - - except Exception as e: - logger.error("Failed to create backup", error=str(e)) - + """Explicit retention operation; never called by ordinary close or writes.""" + self._require_initialized() + collection = self.collections["user_memories"] + records = collection.get(include=["metadatas"]) + ordered = sorted(zip(records["ids"], records["metadatas"]), key=lambda item: json.loads(item[1]["memory"])["created_at"]) + if ordered: + collection.delete(ids=[identifier for identifier, _ in ordered[:max(1, len(ordered) // 5)]]) + count = self.sqlite_conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0] + if count: + self.sqlite_conn.execute("DELETE FROM memories WHERE id IN (SELECT id FROM memories ORDER BY created_at LIMIT ?)", (max(1, count // 5),)) + self.sqlite_conn.commit() + self.sqlite_conn.execute("VACUUM") + + async def _create_backup(self) -> Path: + """Publish a complete snapshot of a quiescent single-writer store. + + Failed copies are not published or counted by retention. The manifest + permits independent file-integrity checks; concurrent writes are unsupported. + """ + self.backup_dir.mkdir(parents=True, exist_ok=True) + backup = self.backup_dir / ("memory_store_" + datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S%f")) + with tempfile.TemporaryDirectory(prefix=".pending-", dir=self.backup_dir) as temporary: + staged = Path(temporary) / "snapshot" + shutil.copytree(self.persist_dir, staged) + if self.sqlite_conn is not None: + with sqlite3.connect(staged / "fallback.db") as destination: + self.sqlite_conn.backup(destination) + hashes = {} + for path in sorted(staged.rglob("*")): + if path.is_file() and path.name != "backup-manifest.json": + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(65536), b""): + digest.update(block) + hashes[path.relative_to(staged).as_posix()] = digest.hexdigest() + (staged / "backup-manifest.json").write_text(json.dumps( + {"complete": True, "created_at": _now(), "model_contract": self._contract, "file_hashes": hashes}, + sort_keys=True, indent=2) + "\n", encoding="utf-8") + staged.replace(backup) + completed = [path for path in sorted(self.backup_dir.glob("memory_store_*")) + if path.is_dir() and (path / "backup-manifest.json").is_file()] + for old in completed[:-5]: + shutil.rmtree(old) + return backup + def cleanup(self) -> None: - """Cleanup connections and resources""" - try: - if self.sqlite_conn: - self.sqlite_conn.close() - - if self.client: - self.client.reset() - - # Clear embedding cache - self.embedding_cache.clear() - - except Exception as e: - logger.error("Failed to cleanup", error=str(e)) - - def _format_memories(self, memories: List[Dict]) -> List[str]: - """Format memories for context""" - return [f"- {m['text']}" for m in memories] \ No newline at end of file + """Close this instance without resetting/deleting persisted user data.""" + if self.sqlite_conn is not None: + self.sqlite_conn.close() + self.sqlite_conn = None + self.collections.clear() + self.client = None # Chroma owns native handle lifecycle; persistence is automatic. + self.embedding_cache.clear() + self.initialized = False + + @staticmethod + def _format_memories(memories: list[dict]) -> list[str]: + return [f"- {memory['text']}" for memory in memories] + + +def type_name(error: Exception) -> str: + return error.__class__.__name__ diff --git a/apps/api/src/memory/models.py b/apps/api/src/memory/models.py index 27e6e91..0bc48ac 100644 --- a/apps/api/src/memory/models.py +++ b/apps/api/src/memory/models.py @@ -10,6 +10,8 @@ import numpy as np from enum import Enum import structlog +from uuid import uuid4 +from .embedding_contract import DEFAULT_EMBEDDING_DIMENSION, MAX_EMBEDDING_DIMENSION, validate_vector from .exceptions import MemoryValidationError # Configure logging @@ -48,7 +50,7 @@ class Config: class Memory(BaseModel): """Memory data model""" - id: str = Field(..., description="Unique memory identifier") + id: str = Field(default_factory=lambda: uuid4().hex, min_length=1, description="Unique memory identifier") text: str = Field(..., min_length=1, max_length=4096) type: MemoryType = Field(..., description="Memory type") salience: float = Field(..., ge=0.0, le=1.0) @@ -57,6 +59,7 @@ class Memory(BaseModel): thread_id: str = Field(..., description="Conversation thread ID") user_id: str = Field(..., description="User ID") persona_id: str = Field(..., description="Persona ID") + embedding_dimension: int = Field(DEFAULT_EMBEDDING_DIMENSION, gt=0, le=MAX_EMBEDDING_DIMENSION) embedding: Optional[List[float]] = Field(None, description="Vector embedding") metadata: MemoryMetadata = Field(default_factory=MemoryMetadata) @@ -64,14 +67,8 @@ class Config: use_enum_values = True @validator("embedding") - def validate_embedding(cls, v: Optional[List[float]]) -> Optional[List[float]]: - """Validate embedding dimensions""" - if v is not None: - if len(v) != 1024: - raise ValueError("Embedding must have 1024 dimensions") - if not all(isinstance(x, float) for x in v): - raise ValueError("Embedding must contain only floats") - return v + def validate_embedding(cls, v: Optional[List[float]], values: Dict[str, Any]) -> Optional[List[float]]: + return validate_vector(v, values.get("embedding_dimension", DEFAULT_EMBEDDING_DIMENSION)) if v is not None else None class MemoryQuery(BaseModel): """Memory query model""" @@ -107,7 +104,7 @@ class MemoryBatch(BaseModel): user_id: str thread_id: str persona_id: str - batch_id: str = Field(..., description="Unique batch identifier") + batch_id: str = Field(default_factory=lambda: uuid4().hex, description="Unique batch identifier") created_at: datetime = Field(default_factory=datetime.utcnow) metadata: Dict[str, Any] = Field(default_factory=dict) @@ -136,18 +133,14 @@ class MemoryVector(BaseModel): """Memory vector model""" memory_id: str + embedding_dimension: int = Field(DEFAULT_EMBEDDING_DIMENSION, gt=0, le=MAX_EMBEDDING_DIMENSION) embedding: List[float] metadata: Dict[str, Any] = Field(default_factory=dict) @validator("embedding") - def validate_vector(cls, v: List[float]) -> List[float]: - """Validate vector data""" - if len(v) != 1024: - raise ValueError("Vector must have 1024 dimensions") - if not all(isinstance(x, float) for x in v): - raise ValueError("Vector must contain only floats") - return v - + def validate_vector(cls, v: List[float], values: Dict[str, Any]) -> List[float]: + return validate_vector(v, values.get("embedding_dimension", DEFAULT_EMBEDDING_DIMENSION)) + def normalize(self) -> None: """Normalize vector to unit length""" norm = np.linalg.norm(self.embedding) diff --git a/apps/api/src/memory/validation.py b/apps/api/src/memory/validation.py index 685b538..0f12e28 100644 --- a/apps/api/src/memory/validation.py +++ b/apps/api/src/memory/validation.py @@ -8,6 +8,8 @@ from datetime import datetime import xxhash import structlog +from uuid import uuid4 +from .embedding_contract import DEFAULT_EMBEDDING_DIMENSION, MAX_EMBEDDING_DIMENSION, validate_vector from .models import MemoryType, MemoryState, MemoryMetadata # re-exported enums/model @@ -15,7 +17,7 @@ class MemoryEvent(BaseModel): - id: str + id: str = Field(default_factory=lambda: uuid4().hex, min_length=1) text: str = Field(..., min_length=1, max_length=4096) type: MemoryType salience: float = Field(..., ge=0.0, le=1.0) @@ -23,30 +25,17 @@ class MemoryEvent(BaseModel): thread_id: str user_id: str persona_id: str + embedding_dimension: int = Field(DEFAULT_EMBEDDING_DIMENSION, gt=0, le=MAX_EMBEDDING_DIMENSION) embedding: Optional[List[float]] = None metadata: MemoryMetadata = Field(default_factory=MemoryMetadata) - @validator("id", pre=True, always=True) - def generate_id(cls, v: Optional[str], values: Dict[str, Any]) -> str: - if v: - return v - text = values.get("text", "") - thread_id = values.get("thread_id", "") - user_id = values.get("user_id", "") - return xxhash.xxh64(f"{user_id}:{thread_id}:{text}".encode()).hexdigest() - @validator("embedding") - def validate_embedding(cls, v: Optional[List[float]]) -> Optional[List[float]]: - if v is not None: - if len(v) != 1024: - raise ValueError("Embedding must have 1024 dimensions") - if not all(isinstance(x, float) for x in v): - raise ValueError("Embedding must contain only floats") - return v + def validate_embedding(cls, v: Optional[List[float]], values: Dict[str, Any]) -> Optional[List[float]]: + return validate_vector(v, values.get("embedding_dimension", DEFAULT_EMBEDDING_DIMENSION)) if v is not None else None @validator("metadata") def validate_metadata(cls, v: MemoryMetadata) -> MemoryMetadata: - size = len(json.dumps(v.dict()).encode("utf-8")) + size = len(json.dumps(v.model_dump(mode="json")).encode("utf-8")) if size > 10 * 1024: raise ValueError("Metadata too large") return v diff --git a/apps/api/tests/test_memory_vectors.py b/apps/api/tests/test_memory_vectors.py deleted file mode 100644 index f8b9802..0000000 --- a/apps/api/tests/test_memory_vectors.py +++ /dev/null @@ -1,319 +0,0 @@ -""" -Memory Vector Store Tests -Created: 2025-11-02 19:41:39 -Author: electricwolfemarshmallowhypertext -""" - -import pytest -import os -import shutil -from datetime import datetime -import numpy as np -from src.memory.memory_engine import MemoryEngine -from src.exceptions import MemoryException - -TEST_DATA_DIR = "test_memory_store" -REDIS_URL = "redis://localhost:6379/0" - -@pytest.fixture(autouse=True) -async def setup_teardown(): - """Setup and teardown test environment""" - # Setup - os.makedirs(TEST_DATA_DIR, exist_ok=True) - - yield - - # Teardown - shutil.rmtree(TEST_DATA_DIR) - -@pytest.fixture -async def memory_engine(): - """Create memory engine instance""" - engine = MemoryEngine( - redis_url=REDIS_URL, - persist_directory=TEST_DATA_DIR, - max_memories=20, - sqlite_fallback_path=f"{TEST_DATA_DIR}/fallback.db" - ) - await engine.initialize() - return engine - -@pytest.mark.asyncio -async def test_embedding_generation(memory_engine): - """Test vector embedding generation""" - # Test basic embedding - text = "Test memory content" - embedding = await memory_engine._get_embedding(text) - assert len(embedding) == 1024 - assert all(isinstance(x, float) for x in embedding) - assert -1 <= min(embedding) <= max(embedding) <= 1 - - # Test consistency - embedding2 = await memory_engine._get_embedding(text) - assert np.allclose(embedding, embedding2, atol=1e-5) - - # Test empty text - with pytest.raises(MemoryException): - await memory_engine._get_embedding("") - - # Test long text - long_text = "test " * 1000 - long_embedding = await memory_engine._get_embedding(long_text) - assert len(long_embedding) == 1024 - -@pytest.mark.asyncio -async def test_memory_storage(memory_engine): - """Test memory storage with vectors""" - # Store test memory - text = "Test memory content" - embedding = await memory_engine._get_embedding(text) - - event = await memory_engine.store_memory( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - text=text, - type="test", - salience=0.5, - embedding=embedding - ) - - # Verify stored memory - assert event["id"] - assert event["text"] == text - assert event["user_id"] == "test_user" - assert event["persona_id"] == "test_persona" - assert event["type"] == "test" - assert isinstance(event["created_at"], str) - - # Test size limits - large_text = "large memory " * 1000 - with pytest.raises(MemoryException, match="Memory size exceeds"): - await memory_engine.store_memory( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - text=large_text, - type="test", - salience=0.5, - embedding=await memory_engine._get_embedding(large_text) - ) - -@pytest.mark.asyncio -async def test_vector_similarity_search(memory_engine): - """Test vector similarity search""" - # Store test memories - memories = [ - "The cat sat on the mat", - "Dogs love playing fetch", - "Birds fly in the sky", - "Fish swim in the ocean", - "Rabbits hop in fields" - ] - - for text in memories: - embedding = await memory_engine._get_embedding(text) - await memory_engine.store_memory( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - text=text, - type="test", - salience=0.5, - embedding=embedding - ) - - # Test exact query - context = await memory_engine.build_context( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - query="Tell me about cats" - ) - assert len(context["memory"]) == 3 # Verify top 3 limit - assert any("cat" in m for m in context["memory"]) - - # Test semantic similarity - context = await memory_engine.build_context( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - query="What about pets?" - ) - assert len(context["memory"]) == 3 - assert any("cat" in m for m in context["memory"]) - assert any("dog" in m for m in context["memory"]) - -@pytest.mark.asyncio -async def test_memory_transfer(memory_engine): - """Test memory context transfer between personas""" - # Store test memories - texts = [ - "Important fact about topic A", - "Crucial information about B", - "Key insight about topic C", - "Random unrelated fact", - "Critical detail about A" - ] - - for text in texts: - embedding = await memory_engine._get_embedding(text) - await memory_engine.store_memory( - user_id="test_user", - thread_id="test_thread", - persona_id="persona_a", - text=text, - type="test", - salience=0.5, - embedding=embedding - ) - - # Test context transfer - transferred = await memory_engine.transfer_context( - user_id="test_user", - from_persona="persona_a", - to_persona="persona_b", - query="Tell me about topic A" - ) - - assert len(transferred) > 0 - assert any("topic A" in m["text"] for m in transferred) - - # Verify transferred memories are queryable - context = await memory_engine.build_context( - user_id="test_user", - thread_id="test_thread", - persona_id="persona_b", - query="topic A" - ) - assert len(context["memory"]) > 0 - assert any("topic A" in m for m in context["memory"]) - -@pytest.mark.asyncio -async def test_memory_cleanup(memory_engine): - """Test memory cleanup and size limits""" - # Store max_memories + 5 memories - for i in range(memory_engine.max_memories + 5): - text = f"Memory {i}" - embedding = await memory_engine._get_embedding(text) - await memory_engine.store_memory( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - text=text, - type="test", - salience=0.5, - embedding=embedding - ) - - # Force cleanup - await memory_engine._cleanup_old_data() - - # Verify memory count - context = await memory_engine.build_context( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - query="Memory" - ) - assert len(context["memory"]) <= memory_engine.max_memories - -@pytest.mark.asyncio -async def test_memory_persistence(memory_engine): - """Test memory persistence across restarts""" - # Store test memory - text = "Persistent memory test" - embedding = await memory_engine._get_embedding(text) - await memory_engine.store_memory( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - text=text, - type="test", - salience=0.5, - embedding=embedding - ) - - # Create new engine instance - new_engine = MemoryEngine( - redis_url=REDIS_URL, - persist_directory=TEST_DATA_DIR, - sqlite_fallback_path=f"{TEST_DATA_DIR}/fallback.db" - ) - await new_engine.initialize() - - # Verify memory persists - context = await new_engine.build_context( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - query=text - ) - assert len(context["memory"]) > 0 - assert any(text in m for m in context["memory"]) - -@pytest.mark.asyncio -async def test_sqlite_fallback(memory_engine): - """Test SQLite fallback functionality""" - # Simulate Chroma failure - memory_engine.client = None - - # Store should use SQLite - text = "Fallback test" - embedding = await memory_engine._get_embedding(text) - event = await memory_engine.store_memory( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - text=text, - type="test", - salience=0.5, - embedding=embedding - ) - - assert event["id"] - - # Verify retrieval from SQLite - context = await memory_engine.build_context( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - query=text - ) - assert len(context["memory"]) > 0 - -@pytest.mark.asyncio -async def test_error_handling(memory_engine): - """Test error handling scenarios""" - # Invalid embedding - with pytest.raises(MemoryException): - await memory_engine.store_memory( - user_id="test_user", - thread_id="test_thread", - persona_id="test_persona", - text="Test", - type="test", - salience=0.5, - embedding=[0] * 100 # Wrong dimension - ) - - # Missing user_id - with pytest.raises(MemoryException): - await memory_engine.store_memory( - user_id="", - thread_id="test_thread", - persona_id="test_persona", - text="Test", - type="test", - salience=0.5, - embedding=await memory_engine._get_embedding("Test") - ) - - # Invalid thread_id - with pytest.raises(MemoryException): - await memory_engine.build_context( - user_id="test_user", - thread_id="", - persona_id="test_persona", - query="Test" - ) \ No newline at end of file diff --git a/docs/adoption_quickstart.md b/docs/adoption_quickstart.md index c8d4ca5..9b5da49 100644 --- a/docs/adoption_quickstart.md +++ b/docs/adoption_quickstart.md @@ -67,4 +67,4 @@ Upload generated outputs as workflow artifacts: - Keep `if-no-files-found: error` in artifact upload so silent output loss fails fast. - On failure, capture step logs and uploaded artifacts from the last successful run for comparison. -The legacy API audit has unresolved migration blockers; see `security_hardening_todo.md`. A passing compiler run is not a production release gate or authenticated truth proof. +The legacy API has only scoped local model/storage and client validation; see `embedding_validation.md` and `security_hardening_todo.md`. A passing compiler run is not a production release gate or authenticated truth proof. diff --git a/docs/embedding_validation.md b/docs/embedding_validation.md new file mode 100644 index 0000000..f848c60 --- /dev/null +++ b/docs/embedding_validation.md @@ -0,0 +1,45 @@ +# Real local embedding/storage validation + +This branch upgrades the **isolated memory engine**, not the entire legacy API or a model-provider integration. The tested path performs actual CPU inference, local Chroma storage and queryable SQLite fallback. No fabricated vectors, Firebase account operations or paid API calls are used. + +## Exact model and runtime + +- Public model: `sentence-transformers/all-MiniLM-L6-v2`. +- Immutable revision: `1110a243fdf4706b3f48f1d95db1a4f5529b4d41`. +- `model.safetensors` SHA256: `53aa51172d142c89d9012cce15ae4d6cc0ca6895895114379cacb4fab128d9db`. +- Ten approved safetensors/config/tokenizer/pooling files; no pickle weights or custom Python model code. +- Tested: Sentence Transformers 6.0.0, official Torch 2.14.1+cpu, Transformers 5.17.0, NumPy 1.26.4, Chroma 0.4.15 / hnswlib 0.7.3, PostHog 5.4.0, Pydantic 2.13.5 on Linux/Python 3.12.14. +- Engine loads with `trust_remote_code=False`, safetensors required, CPU device, and `local_files_only=True` by default. It does not silently fetch another hardcoded embedding model. + +The complete tested dependency freeze, model-file manifest, downloads/checksums and offline command harness are in the repair evidence. Chroma's old native hnsw package required a local GCC build on Python 3.12; this is not a Windows or Python 3.11 execution claim. The API requirement `torch==2.14.1` permits the verified `+cpu` build; select the official CPU wheel explicitly rather than downloading an unnecessary CUDA closure for CPU validation. + +## Exercise the actual engine + +Provision an approved local model snapshot first; tests never download it. In the isolated model runtime: + +```bash +AGENTMD_TEST_MODEL_PATH=/absolute/path/to/model \ +HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 \ +python -m pytest -q tests/memory/test_memory_vectors.py +``` + +Tests are outside `apps/api/tests/conftest.py`, whose legacy Redis/Firebase fixtures can mutate real services. Do not interpret those misleadingly named fixtures as mocks or run them against real accounts without authorization. The ordinary CLI suite remains separate and does not install a large model runtime. + +The observed checks cover scalar/batch 384-dimensional finite inference, repeat consistency and semantic ordering; real text/vector writes; thread/user/persona scope; transfers with distinct IDs; separate-process persistence reopen; actual missing-collection failure and compressed SQLite fallback/reopen; copy-to-fresh-location backup restore; unchanged original data on incompatible model/shape; and failed-copy backup cleanup. Synthetic security/error-injection tests remain deliberate synthetic tests, not claims of a live provider call. + +## Storage contract and safe migration boundaries + +The dimension comes from the loaded model, not a hardcoded 1024. Both schemas and storage require finite float32-representable vectors; the actual engine's model dimension is authoritative. Model/dimension labels alone are insufficient: stores also bind a SHA256 fingerprint of loaded weight tensors, tokenizer, transformer/pooling configuration and execution-library versions. A same-sized changed model cannot silently reuse a store. + +A nonempty old store without that complete contract, or with a different contract, is rejected. This repair does **not** migrate production data. Back up a copy and plan explicit re-embedding into a new store before any production migration. A library/build change can intentionally invalidate the strict fingerprint. + +- Chroma stores text documents and explicit vectors; SQLite fallback stores compressed text plus float32 vectors and remains queryable. +- Memory IDs include user/thread/persona/text; transfers cannot overwrite the source persona. +- Ordinary cleanup closes instance references, never resets or deletes persisted data. +- Quota checks reject new work rather than silently deleting old memories; the byte estimate is not a hard filesystem quota. +- Backups live outside the source tree, are staged and published only after completion, and include file-hash manifests. Retention does not count failed partial copies. A quiescent single writer is required; concurrent-writer/crash consistency is not guaranteed. +- A degraded Chroma operation is labelled via `storage_degraded`; local fallback is not a remote database or production availability guarantee. + +## What remains unverified + +Full legacy API boot and service wiring, live cloud/auth/provider integrations, existing production-store migration, native Windows behavior and model quality beyond the recorded local semantic checks. Source/hash integrity is not an authentication or broad model-safety proof. diff --git a/docs/proof_note.md b/docs/proof_note.md index 24bf06a..cec0c4f 100644 --- a/docs/proof_note.md +++ b/docs/proof_note.md @@ -27,7 +27,7 @@ Real verification logs and report are in `.agentmd/verification/`; execution rec ## What is not established -- The legacy API is not fully installed/booted/validated; its remaining dependency audit findings remain a release blocker. See `docs/security_hardening_todo.md`. +- The isolated embedding/storage path and Google/telemetry slices have real local validation, but the full legacy API is not installed/booted/validated as one deployed service. See `embedding_validation.md` and `security_hardening_todo.md`. - Example provider artifacts do not establish live provider calls. The local runner is not a tool-specific integration or sandbox. - A source's `verified` claim is still asserted, not authenticated. Receipts establish bytes and local process outcomes, not semantic truth. - Skill validation receipts bind externally supplied scores and hashes. They do not execute or authenticate an evaluator or prove improvement. diff --git a/docs/security_hardening_todo.md b/docs/security_hardening_todo.md index 0e50900..449e052 100644 --- a/docs/security_hardening_todo.md +++ b/docs/security_hardening_todo.md @@ -25,19 +25,19 @@ This does **not** establish historical credential safety or account cleanup: - Empty discovery remains zero evidence, compiler receipts do not feed themselves back, and prepared receipts are not successful execution evidence. - Local `run` now executes explicit argv and records actual failures/timeouts; it is not a sandbox/provider integration. -## Dependency gate — not yet green +## Current dependency and model validation The October advisory refresh found new issues despite September's successful audits. - CLI/CI lock: certifi updated to `2026.10.19`, filelock to `4.2.1`; hash-lock installation and current audit must pass on the final branch. Colorama is retained explicitly for Windows hash-locked installs. - Legacy API: unused `python-jose` removed (Firebase Admin handles token verification); aiohttp updated to `3.14.4`, Black to `26.10.1` with focused genuine local smoke checks. -- **Blocked: sentence-transformers `2.2.2`.** The scanner suggests `5.6.0`, but upstream says trust enforcement for local custom modules begins in `6.0.0`. A blind major upgrade is not compatibility evidence. Existing API issues include the memory-engine exception import, legacy Chroma configuration, and 384-vs-1024 embedding dimensions. A real local-model/storage migration is needed; no model was downloaded or invoked for this repair. +- **Sentence Transformers `6.0.0`:** upgraded after approved real offline MiniLM CPU inference and Chroma/SQLite persistence/query/transfer/backup checks, not just a scanner pin bump. Corrected the old exception import, obsolete Chroma API, dimensions, text/thread/persona storage, fallback retrieval and destructive cleanup. Stores now require an actual loaded-model fingerprint and finite float32 vectors; existing unknown/mismatched stores are rejected, not silently migrated. See `embedding_validation.md`. - **Coordinated OpenTelemetry/Firestore update:** API/SDK/OTLP exporter `1.45.1`, instrumentation `0.66b1`, Firestore `2.34.1`, protobuf `6.33.6`. The old Firestore `<5` protobuf conflict was resolved by a tested compatible Firestore update. Real offline client/protobuf roundtrips and instrumented HTTP/in-memory span export passed with existing Firebase Admin/AI Platform pins; this is not a live Firestore service or full API boot test. -- The API direct audit remains intentionally failing while those pins remain; the CI gate has not been removed/ignored. Direct auditing does not cover full transitive installation or production reachability. +- Audit gates remain enabled with no ignored findings. Direct auditing does not cover full transitive installation or production reachability; separate resolved slices/model runtime were also audited. Full legacy API boot and live cloud/auth integration remain unverified. ## Required before release -- [ ] Resolve remaining legacy API dependency gates, or obtain an explicit product/support-scope decision; do not silently suppress the gate. +- [ ] Revalidate dependency gates and the supported legacy API/service scope on the release candidate; do not equate isolated model/client checks with full production integration. - [ ] Re-run fresh hash-locked install, CLI outcomes, real local verification artifact, compiler repeat/integrity checks and all dependency audits on the final candidate. - [ ] Run new behavior and wrappers natively on Windows/Python 3.11; Linux verification is not a Windows claim. - [ ] Review local diff, unsigned-receipt limitations and all account-level unknowns. diff --git a/tests/memory/test_memory_vectors.py b/tests/memory/test_memory_vectors.py new file mode 100644 index 0000000..a06278f --- /dev/null +++ b/tests/memory/test_memory_vectors.py @@ -0,0 +1,279 @@ +"""Real local-model/vector regressions; deliberately outside live API conftest. + +No model download is performed by these tests. Set AGENTMD_TEST_MODEL_PATH to an +approved local MiniLM safetensors snapshot. No mocks or fabricated embeddings. +""" +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import socket +import sys + +import pytest + +MODEL = os.environ.get("AGENTMD_TEST_MODEL_PATH") +if not MODEL: + pytest.skip("Real local MiniLM snapshot required: set AGENTMD_TEST_MODEL_PATH", allow_module_level=True) + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps/api")) +import numpy as np +from src.memory.memory_engine import MemoryEngine +from src.memory.exceptions import MemoryException +from src.memory.models import Memory, MemoryVector, create_memory, create_batch +from src.memory.validation import MemoryEvent, validate_memory_input + +MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2@1110a243fdf4706b3f48f1d95db1a4f5529b4d41" + + +def run(coroutine): + return asyncio.run(coroutine) + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch): + original = socket.socket.connect + def reject_inet(sock, address): + if sock.family in (socket.AF_INET, socket.AF_INET6): + raise OSError("Network disabled for local model regression") + return original(sock, address) + monkeypatch.setattr(socket.socket, "connect", reject_inet) + monkeypatch.setenv("HF_HUB_OFFLINE", "1") + monkeypatch.setenv("TRANSFORMERS_OFFLINE", "1") + + +def make_engine(root: Path, **kwargs): + return MemoryEngine(persist_dir=str(root / "store"), embedding_model=MODEL, + embedding_model_id=MODEL_ID, max_size_mb=20, **kwargs) + + +@pytest.fixture() +def engine(tmp_path): + instance = make_engine(tmp_path) + run(instance.initialize()) + try: + yield instance + finally: + instance.cleanup() + + +def store(engine, text="Cats chase mice", user="user-a", thread="thread-a", persona="persona-a", **kwargs): + return run(engine.store_memory(user, thread, persona, text, "fact", 0.5, **kwargs)) + + +def test_real_model_inference_cache_and_semantics(engine): + cat = run(engine._get_embedding("Cats chase mice")) + query = run(engine._get_embedding("Tell me about cats")) + unrelated = run(engine._get_embedding("The stock market trades corporate bonds")) + assert engine.embedding_dimension == len(cat) == 384 + assert np.isfinite(cat).all() + assert np.linalg.norm(cat) == pytest.approx(1.0, abs=1e-5) + assert np.dot(cat, query) > np.dot(unrelated, query) + assert cat == run(engine._get_embedding("Cats chase mice")) + altered = run(engine._get_embedding("Cats chase mice")); altered[0] = 999.0 + assert run(engine._get_embedding("Cats chase mice"))[0] != 999.0 + with pytest.raises(MemoryException): + run(engine._get_embedding("")) + + +def test_real_chroma_text_storage_scope_and_ids(engine): + first = store(engine) + duplicate = store(engine) + other_persona = store(engine, persona="persona-b") + store(engine, text="Another user's secret-free fixture", user="user-b") + assert first["storage_backend"] == duplicate["storage_backend"] == "chroma" + assert first["id"] == duplicate["id"] != other_persona["id"] + records = engine.collections["user_memories"].get(ids=[first["id"]], include=["documents", "metadatas", "embeddings"]) + assert records["documents"] == ["Cats chase mice"] + assert records["metadatas"][0]["thread_id"] == "thread-a" + assert len(records["embeddings"][0]) == 384 + session = run(engine.load_session("user-a", "thread-a")) + assert len(session["messages"]) == 2 + assert all(item["user_id"] == "user-a" for item in session["messages"]) + assert engine.sqlite_conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0] == 0 + + +def test_real_transfer_requests_embeddings_and_preserves_personas(engine): + source = store(engine) + moved = run(engine.transfer_context("user-a", "persona-a", "persona-b", "cats")) + assert moved and moved[0]["id"] != source["id"] + assert moved[0]["storage_backend"] == "chroma" + assert engine.collections["user_memories"].count() == 2 + target = run(engine.build_context("user-a", "thread-a", "persona-b", "cats")) + assert target["memory"] == ["- Cats chase mice"] + with pytest.raises(MemoryException): + run(engine.transfer_context("user-a", "persona-a", "persona-a", "cats")) + + +def test_real_cleanup_reopen_does_not_reset_store(engine, tmp_path): + first = store(engine) + engine.cleanup() + reopened = make_engine(tmp_path) + try: + run(reopened.initialize()) + assert reopened.collections["user_memories"].count() == 1 + assert run(reopened.load_session("user-a", "thread-a"))["messages"][0]["id"] == first["id"] + finally: + reopened.cleanup() + + +def test_real_collection_failure_has_queryable_persistent_sqlite_fallback(engine, tmp_path): + # Actual backend operation removes this synthetic collection; no mocked client. + engine.client.delete_collection("user_memories") + event = store(engine, text="Fallback memories persist") + assert event["storage_backend"] == "sqlite" + content, vector = engine.sqlite_conn.execute("SELECT content,embedding FROM memories").fetchone() + import zstandard + assert zstandard.ZstdDecompressor().decompress(content).decode() == event["text"] + assert np.frombuffer(vector, dtype=np.float32).shape == (384,) + assert run(engine.build_context("user-a", "thread-a", "persona-a", "Fallback memories"))["memory"] == ["- Fallback memories persist"] + engine.cleanup() + reopened = make_engine(tmp_path) + try: + run(reopened.initialize()) + assert run(reopened.load_session("user-a", "thread-a"))["messages"][0]["id"] == event["id"] + moved = run(reopened.transfer_context("user-a", "persona-a", "persona-b", "Fallback memories")) + assert moved and moved[0]["storage_backend"] == "chroma" + finally: + reopened.cleanup() + + +def test_model_dimension_and_finiteness_are_enforced_at_storage(engine): + for vector in [[0.0] * 1024, [float("nan")] * 384, [float("inf")] * 384, [1e100] * 384]: + with pytest.raises(MemoryException): + store(engine, embedding=vector) + assert engine.collections["user_memories"].count() == 0 + assert engine.sqlite_conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0] == 0 + + +def test_model_contract_mismatch_does_not_modify_persisted_records(engine, tmp_path): + original = store(engine) + engine.collections["user_memories"].modify(metadata={**engine.collections["user_memories"].metadata, "embedding_dimension": 1024}) + other = make_engine(tmp_path) + with pytest.raises(MemoryException): + run(other.initialize()) + assert engine.collections["user_memories"].get(ids=[original["id"]])["ids"] == [original["id"]] + + +def test_backup_directory_and_retention_are_non_recursive(engine, tmp_path): + store(engine) + backup = run(engine._create_backup()) + assert backup.parent == engine.backup_dir + assert engine.persist_dir not in backup.parents + assert (backup / "fallback.db").is_file() + assert not list(backup.rglob("memory_store_*")) + with pytest.raises(ValueError, match="Backup directory"): + make_engine(tmp_path, backup_dir=str(engine.persist_dir / "backups")) + + +def test_budget_failure_does_not_delete_prior_memories(engine): + first = store(engine) + engine.max_size_bytes = 1 + with pytest.raises(MemoryException): + store(engine, text="This write must not occur") + assert engine.collections["user_memories"].count() == 1 + assert run(engine.load_session("user-a", "thread-a"))["messages"][0]["id"] == first["id"] + + +def test_schema_helpers_share_actual_default_vector_contract(engine): + vector = run(engine._get_embedding("A real vector")) + item = create_memory("A real vector", "thread", "user", "persona") + assert item.id and create_batch([item], "user", "thread", "persona").batch_id + assert len(MemoryVector(memory_id=item.id, embedding=vector).embedding) == 384 + assert Memory(**{**item.model_dump(), "embedding": vector}).embedding_dimension == 384 + event = validate_memory_input("A real vector", "thread", "user", "persona") + assert event["id"] and MemoryEvent(**{**event, "embedding": vector}).embedding_dimension == 384 + with pytest.raises(ValueError): + MemoryVector(memory_id=item.id, embedding=[float("nan")] * 384) + # Schema metadata can declare another model, but the actual store still rejects it. + assert MemoryVector(memory_id=item.id, embedding_dimension=2, embedding=[1.0, 0.0]).embedding_dimension == 2 + with pytest.raises(MemoryException): + store(engine, embedding=[1.0, 0.0]) + + +@pytest.mark.parametrize("change", ["pooling", "prompt"]) +def test_actual_encode_change_same_dimension_and_label_is_rejected(engine, tmp_path, change): + import shutil + original = store(engine) + changed_model = tmp_path / "changed-model" + shutil.copytree(MODEL, changed_model) + config_path = changed_model / ("1_Pooling/config.json" if change == "pooling" else "config_sentence_transformers.json") + config = json.loads(config_path.read_text()) + if change == "pooling": + config["pooling_mode_mean_tokens"] = False + config["pooling_mode_cls_token"] = True + else: + config["prompts"] = {"changed": "Represent this question for retrieval: "} + config["default_prompt_name"] = "changed" + config_path.write_text(json.dumps(config)) + alternate = MemoryEngine(persist_dir=str(engine.persist_dir), embedding_model=str(changed_model), + embedding_model_id=MODEL_ID, max_size_mb=20) + assert alternate.embedding_dimension == engine.embedding_dimension == 384 + assert alternate.embedding_fingerprint != engine.embedding_fingerprint + with pytest.raises(MemoryException): + run(alternate.initialize()) + assert engine.collections["user_memories"].get(ids=[original["id"]])["ids"] == [original["id"]] + + +def test_missing_sqlite_contract_row_never_labels_unknown_vectors(engine, tmp_path): + engine.client.delete_collection("user_memories") + event = store(engine, text="Retain unknown-contract fallback") + engine.sqlite_conn.execute("DELETE FROM engine_metadata WHERE key='embedding_contract'") + engine.sqlite_conn.commit() + other = make_engine(tmp_path) + with pytest.raises(MemoryException): + run(other.initialize()) + assert engine.sqlite_conn.execute("SELECT COUNT(*) FROM engine_metadata").fetchone()[0] == 0 + assert engine.sqlite_conn.execute("SELECT id FROM memories").fetchone()[0] == event["id"] + + +def test_failed_backup_copy_does_not_publish_or_prune(engine, monkeypatch): + import shutil + before = sorted(engine.backup_dir.iterdir()) + def fail_copy(source, target): + target.mkdir() + (target / "partial").write_text("synthetic partial copy") + raise OSError("synthetic disk failure") + monkeypatch.setattr(shutil, "copytree", fail_copy) + with pytest.raises(OSError): + run(engine._create_backup()) + assert sorted(engine.backup_dir.iterdir()) == before + + +def test_real_backup_restores_both_chroma_and_sqlite_data(engine, tmp_path): + import hashlib + import shutil + engine.client.delete_collection("user_memories") + fallback = store(engine, text="SQLite backup record") + run(engine._setup_collections()) + primary = store(engine, text="Chroma backup record") + assert fallback["storage_backend"] == "sqlite" and primary["storage_backend"] == "chroma" + backup = run(engine._create_backup()) + manifest = json.loads((backup / "backup-manifest.json").read_text()) + assert manifest["complete"] is True + for relative, expected in manifest["file_hashes"].items(): + assert hashlib.sha256((backup / relative).read_bytes()).hexdigest() == expected + restore_root = tmp_path / "restored" + shutil.copytree(backup, restore_root / "store") + restored = make_engine(restore_root) + try: + run(restored.initialize()) + messages = run(restored.load_session("user-a", "thread-a"))["messages"] + assert {message["id"] for message in messages} == {fallback["id"], primary["id"]} + assert restored.collections["user_memories"].count() == 1 + assert restored.sqlite_conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0] == 1 + context = run(restored.build_context("user-a", "thread-a", "persona-a", "backup record", top_k=2)) + assert set(context["memory"]) == {"- SQLite backup record", "- Chroma backup record"} + finally: + restored.cleanup() + + +def test_backup_retention_cannot_contain_live_external_sqlite(tmp_path): + backups = tmp_path / "backups" + with pytest.raises(ValueError, match="Backup directory"): + MemoryEngine(persist_dir=str(tmp_path / "live"), backup_dir=str(backups), + sqlite_fallback_path=str(backups / "memory_store_old" / "live.db"), embedding_model=MODEL) + assert not backups.exists() + assert not (tmp_path / "live").exists()