From e5934563b2d21f8eff876e8abbd0a96dcf1def75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 20:41:19 +0000 Subject: [PATCH 1/3] feat: durable persistence + verifiable audit log + operator CLI (#126, #127, #147, #124) Add a coherent "durable, verifiable, operable audit trail" feature set: - #126 Pluggable persistence: new weaver_kernel.stores package with TraceStoreProtocol / RevocationStoreProtocol / HandleStoreProtocol and stdlib-only durable backends (SQLiteTraceStore, JsonlTraceStore, SQLiteRevocationStore). In-memory stores remain the defaults; backends are injected via constructor. Revocation now lives behind a protocol on HMACTokenProvider, so a revoked token stays revoked across a restart when a SQLiteRevocationStore is used. - #127 Hash-chained verifiable audit log: prev_hash/record_hash envelope (HMAC-SHA256, keyed by WEAVER_KERNEL_SECRET), verify_chain() detecting mutation/insertion/deletion/reorder, and SQLiteTraceStore.prune() that keeps the retained suffix verifiable via a checkpoint. Tamper-evidence, not non-repudiation (documented). - #147 + #124 weaver-kernel CLI: `audit list|show|verify|export` over a persisted store (redaction-safe, --json everywhere, non-zero exit on tamper) and `doctor` preflight checks with token/audit self-test vectors. argparse, stdlib-only, registered via [project.scripts]. Supporting changes: - Extract HMAC secret loading to weaver_kernel._secrets (shared by token signing and audit-chain hashing) to avoid an import cycle. - Type Kernel(trace_store=...) against TraceStoreProtocol. - Fix a latent grant-cap bypass in HandleStore.expand surfaced by the existing property test: a negative limit/offset is now rejected with INVALID_CONSTRAINT instead of slicing past max_rows. - Docs (architecture, security integrity model, new docs/cli.md), CHANGELOG (0.11.0), and an offline examples/persistent_audit_demo.py wired into `make example`. make ci: 639 passed, 1 skipped; fmt-check, lint, mypy --strict, examples green. https://claude.ai/code/session_014vcsXSCqnFprxCkMiBQGtY --- AGENTS.md | 3 + CHANGELOG.md | 35 ++++ Makefile | 1 + docs/architecture.md | 43 ++++ docs/cli.md | 82 ++++++++ docs/security.md | 28 +++ examples/persistent_audit_demo.py | 103 ++++++++++ pyproject.toml | 5 +- src/weaver_kernel/__init__.py | 32 +++ src/weaver_kernel/_secrets.py | 64 ++++++ src/weaver_kernel/cli/__init__.py | 51 +++++ src/weaver_kernel/cli/_audit.py | 216 ++++++++++++++++++++ src/weaver_kernel/cli/_doctor.py | 165 ++++++++++++++++ src/weaver_kernel/handles.py | 13 ++ src/weaver_kernel/kernel/__init__.py | 7 +- src/weaver_kernel/kernel/_invoke.py | 6 +- src/weaver_kernel/stores/__init__.py | 59 ++++++ src/weaver_kernel/stores/_protocols.py | 84 ++++++++ src/weaver_kernel/stores/_trace_codec.py | 50 +++++ src/weaver_kernel/stores/audit_chain.py | 173 ++++++++++++++++ src/weaver_kernel/stores/jsonl.py | 102 ++++++++++ src/weaver_kernel/stores/memory.py | 53 +++++ src/weaver_kernel/stores/sqlite.py | 242 +++++++++++++++++++++++ src/weaver_kernel/tokens.py | 63 ++---- tests/test_audit_chain.py | 106 ++++++++++ tests/test_cli_audit.py | 112 +++++++++++ tests/test_cli_doctor.py | 42 ++++ tests/test_handles.py | 18 ++ tests/test_stores_jsonl.py | 90 +++++++++ tests/test_stores_sqlite.py | 170 ++++++++++++++++ tests/test_tokens.py | 14 +- 31 files changed, 2172 insertions(+), 60 deletions(-) create mode 100644 docs/cli.md create mode 100644 examples/persistent_audit_demo.py create mode 100644 src/weaver_kernel/_secrets.py create mode 100644 src/weaver_kernel/cli/__init__.py create mode 100644 src/weaver_kernel/cli/_audit.py create mode 100644 src/weaver_kernel/cli/_doctor.py create mode 100644 src/weaver_kernel/stores/__init__.py create mode 100644 src/weaver_kernel/stores/_protocols.py create mode 100644 src/weaver_kernel/stores/_trace_codec.py create mode 100644 src/weaver_kernel/stores/audit_chain.py create mode 100644 src/weaver_kernel/stores/jsonl.py create mode 100644 src/weaver_kernel/stores/memory.py create mode 100644 src/weaver_kernel/stores/sqlite.py create mode 100644 tests/test_audit_chain.py create mode 100644 tests/test_cli_audit.py create mode 100644 tests/test_cli_doctor.py create mode 100644 tests/test_stores_jsonl.py create mode 100644 tests/test_stores_sqlite.py diff --git a/AGENTS.md b/AGENTS.md index de88aed..d484fc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,8 @@ reference this file and add only tool-specific guidance. src/weaver_kernel/ — library source (one module per concern, ≤300 lines each) drivers/ — capability drivers (one file per driver) firewall/ — context firewall (redaction, summarization, budgets) + stores/ — pluggable persistence (SQLite/JSONL) + verifiable audit chain + cli/ — `weaver-kernel` console entry point (audit, doctor) tests/ — pytest suite (one test file per module) examples/ — runnable demos (prefer offline; network OK with fallback) docs/ — reference documentation @@ -139,6 +141,7 @@ See [docs/agent-context/review-checklist.md](docs/agent-context/review-checklist | Capability design conventions | [docs/capabilities.md](docs/capabilities.md) | | Context firewall details | [docs/context_firewall.md](docs/context_firewall.md) | | Action trace export contract | [docs/trace_export.md](docs/trace_export.md) | +| Command-line interface (`weaver-kernel`) | [docs/cli.md](docs/cli.md) | ## Update policy diff --git a/CHANGELOG.md b/CHANGELOG.md index ac9d8d7..ee78c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] - 2026-06-13 + +### Added +- **Pluggable persistence for the trace and revocation stores (#126).** New + `weaver_kernel.stores` package defining `TraceStoreProtocol`, + `RevocationStoreProtocol`, and `HandleStoreProtocol`, with stdlib-only durable + backends: `SQLiteTraceStore`, `JsonlTraceStore` (append-only), and + `SQLiteRevocationStore`. The in-memory `TraceStore` / `HMACTokenProvider` + remain the defaults — inject a backend via constructor to opt in. A token + revoked through a `SQLiteRevocationStore` stays revoked after a process + restart. No new runtime dependencies. +- **Hash-chained, verifiable audit log (#127).** Persisted traces are wrapped in + a `prev_hash`/`record_hash` chain (HMAC-SHA256, keyed by + `WEAVER_KERNEL_SECRET`). `verify_chain()` (and `SQLiteTraceStore.verify_chain()` + / `JsonlTraceStore.verify_chain()`) detect mutation, insertion, deletion, and + reordering, reporting the first divergent record. `SQLiteTraceStore.prune()` + drops old records while preserving verifiability of the retained suffix via a + checkpoint. Tamper-evidence, not non-repudiation — see `docs/security.md`. +- **`weaver-kernel` operator CLI.** `weaver-kernel audit list|show|verify|export` + inspects, filters, verifies, and exports a persisted trace store, with `--json` + on every subcommand and redaction-safe output by construction (#147). + `weaver-kernel doctor` preflight-checks the environment, optional extras, and + token / audit-chain self-test vectors (#124). Registered via + `[project.scripts]`; argparse, stdlib-only. See `docs/cli.md`. +- `examples/persistent_audit_demo.py` — offline end-to-end demo of durable + hash-chained audit + tamper detection + durable revocation. + +### Changed +- HMAC secret loading moved to `weaver_kernel._secrets` (shared by token signing + and audit-chain hashing). `HMACTokenProvider` now accepts a `revocation_store` + and delegates revocation to it; behaviour with the default in-memory store is + unchanged. +- `Kernel(trace_store=...)` is now typed against `TraceStoreProtocol`, so any + conforming backend (including the durable ones) can be injected. + ## [0.10.0] - 2026-06-07 ### Changed diff --git a/Makefile b/Makefile index 0f9d441..d92f3a4 100644 --- a/Makefile +++ b/Makefile @@ -26,5 +26,6 @@ example: python examples/chainweaver_flow.py python examples/evaluation_artifact_policy.py python examples/trace_export_demo.py + python examples/persistent_audit_demo.py ci: fmt-check lint type test example diff --git a/docs/architecture.md b/docs/architecture.md index f626275..8140c54 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,6 +158,49 @@ Records every `ActionTrace`. `explain(action_id)` returns the full audit record. `export_action_trace` / `export_action_traces` serialise traces into a stable, versioned, JSON-serialisable shape for downstream analysis tools (distinct from the OpenTelemetry observability export); `Kernel.list_traces()` is the public accessor that feeds them the audit trail. See [trace_export.md](trace_export.md). +## Persistence & durable stores + +The stateful stores are protocol-based seams (`weaver_kernel.stores`), mirroring +the `Driver` / `PolicyEngine` pattern. The in-memory implementations are the +defaults; durable backends are opt-in via constructor injection. + +| Protocol | Default (in-memory) | Durable backends | Injected via | +|----------|--------------------|------------------|--------------| +| `TraceStoreProtocol` | `TraceStore` | `SQLiteTraceStore`, `JsonlTraceStore` | `Kernel(trace_store=...)` | +| `RevocationStoreProtocol` | `InMemoryRevocationStore` | `SQLiteRevocationStore` | `HMACTokenProvider(revocation_store=...)` | +| `HandleStoreProtocol` | `HandleStore` | *(none yet — see below)* | `Kernel(handle_store=...)` | + +```python +from weaver_kernel import Kernel, HMACTokenProvider +from weaver_kernel.stores import SQLiteTraceStore, SQLiteRevocationStore + +kernel = Kernel( + registry, + token_provider=HMACTokenProvider(revocation_store=SQLiteRevocationStore("revoked.db")), + trace_store=SQLiteTraceStore("audit.db"), +) +``` + +**Backend selection.** Use the in-memory defaults for ephemeral or single-process +use. Use `SQLiteTraceStore` for a durable, queryable, hash-chained audit trail +that survives restarts and supports retention pruning; use `JsonlTraceStore` for +an append-only log that is easy to ship to a collector. Use +`SQLiteRevocationStore` when `revoke()` / `revoke_all()` must outlive a process +or apply across workers sharing a database file. All durable backends use only +the standard library (`sqlite3`, `json`) — no new runtime dependency. + +**Verifiable audit chain.** Persisted traces are hash-chained +(`prev_hash`/`record_hash`, HMAC-SHA256 keyed by `WEAVER_KERNEL_SECRET`). +`verify_chain()` detects mutation, insertion, deletion, and reordering; +`SQLiteTraceStore.prune(before=...)` enforces retention while keeping the +retained suffix verifiable via a checkpoint. The integrity model and its limits +are documented in [security.md](security.md#audit-log-integrity-hash-chain). + +**Handle persistence is intentionally not shipped yet.** `HandleStoreProtocol` is +defined so a durable backend can be added without a breaking change, but handles +are short-lived, TTL-bounded result caches whose durability matters far less than +the audit trail's; only the in-memory `HandleStore` ships today. + ### Adapters (`weaver_kernel.adapters`) Vendor-specific tool-format adapters that translate between `Capability` objects and the tool shapes used by LLM provider APIs: diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..37dec07 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,82 @@ +# Command-line interface + +agent-kernel installs a single console entry point, `weaver-kernel`, with two +subcommands. Both are stdlib-only (argparse) and depend on no third-party +packages. + +```text +weaver-kernel audit — inspect, filter, verify, and export persisted action traces +weaver-kernel doctor — preflight-check the local environment and self-test vectors +``` + +## `weaver-kernel audit` + +Operates on a **persisted** trace store — a `SQLiteTraceStore` database or a +`JsonlTraceStore` file (see [architecture.md](architecture.md#persistence--durable-stores)). +The format is inferred from the path suffix (`.jsonl` → JSONL, otherwise SQLite) +and can be forced with `--format`. Pass `--secret` to verify a chain written with +an explicit secret; by default the CLI uses `WEAVER_KERNEL_SECRET`. + +Output is redaction-safe by construction: the CLI renders only what an +`ActionTrace` already holds. No flag surfaces raw driver output. + +> Traces describe **authorised** invocations only. A *denied* request never +> produces an `ActionTrace` (policy gates before invocation, per I-02), so +> filtering is by outcome (`--status succeeded|failed`), not by an +> allow/deny/ask decision — the trace store does not record denials. + +### `audit list` + +Table (or `--json`) view with filters: + +```bash +weaver-kernel audit list --store audit.db \ + --principal u1 --capability billing.list_invoices \ + --status succeeded --since 2026-01-01 --until 2026-02-01 --limit 50 +``` + +### `audit show` + +Full redaction-safe detail for one action (the CLI face of `kernel.explain()`): + +```bash +weaver-kernel audit show --store audit.db +``` + +Exits non-zero with an error on stderr if the action id is unknown. + +### `audit verify` + +Runs chain verification and reports OK or the first divergent record. **Exits +non-zero** when tampering is detected — suitable for a cron / CI integrity check: + +```bash +weaver-kernel audit verify --store audit.db # → "OK: Verified N record(s)." +weaver-kernel audit verify --store audit.db --json # → {"ok": true, ...} +``` + +### `audit export` + +Filtered export as JSONL (one redaction-safe trace per line), to stdout or a +file. Uses the same filter flags as `list`: + +```bash +weaver-kernel audit export --store audit.db --principal u1 --out u1-traces.jsonl +``` + +## `weaver-kernel doctor` + +Preflight checks for a local setup. Reports each check as `ok` / `warn` / `error` +and **exits non-zero only when a check errors** (a broken build). A missing +`WEAVER_KERNEL_SECRET` is a *warning* (insecure demo-only configuration), not a +failure. + +```bash +weaver-kernel doctor # human-readable +weaver-kernel doctor --json # machine-readable list of checks +``` + +Checks: Python version; whether `WEAVER_KERNEL_SECRET` is set; availability of the +optional `policy` / `mcp` / `otel` extras; a token sign/verify + tamper-detection +self-test vector; and an audit-chain build/verify/mutate self-test vector. Secret +material is never printed — only whether a secret is configured. diff --git a/docs/security.md b/docs/security.md index 4bbe95f..da2b7cc 100644 --- a/docs/security.md +++ b/docs/security.md @@ -81,6 +81,34 @@ strips payload-like fields (`payload`, `content`, `value`, `memory`, `text`, `memory.`. Non-sensitive metadata keys (`key`, `id`, `scope`, ...) are preserved so audit can still confirm an action took place. +## Audit-log integrity (hash chain) + +When traces are persisted to a durable store (`SQLiteTraceStore`, +`JsonlTraceStore`), each record is wrapped in a hash chain: `record_hash = +HMAC-SHA256(secret, {seq, prev_hash, trace})`, where `prev_hash` is the previous +record's hash (the first record links to a genesis value). `verify_chain()` +recomputes every hash and checks the linkage, so it detects: + +- **mutation** of any persisted record (recomputed hash diverges), +- **insertion**, **deletion**, or **reordering** (broken `prev_hash` linkage or a + non-contiguous `seq`), + +and reports the `seq` of the first divergent record. `SQLiteTraceStore.prune()` +removes old records while preserving verifiability of the retained suffix by +recording the last pruned record's hash as a checkpoint. + +**What this is — and is not.** This is **tamper-evidence**: anyone who does not +hold `WEAVER_KERNEL_SECRET` cannot alter the log without `verify_chain()` +detecting it. It is **not non-repudiation**: a host that controls the secret can +forge a self-consistent chain, and the same secret signs tokens, so the audit +log is only as trustworthy as secret custody. It does not encrypt trace contents +at rest, and it does not anchor the chain to an external timestamping authority. +The chain payload is the redaction-safe export shape — chaining adds no field the +in-memory trace did not already hold and cannot widen the I-01 boundary. + +The CLI exposes verification to operators: `weaver-kernel audit verify --store +audit.db` exits non-zero on any divergence (see [cli.md](cli.md)). + ## Security disclaimers > **v0.1 is not production-hardened for real authentication.** diff --git a/examples/persistent_audit_demo.py b/examples/persistent_audit_demo.py new file mode 100644 index 0000000..82f5fc8 --- /dev/null +++ b/examples/persistent_audit_demo.py @@ -0,0 +1,103 @@ +"""Durable, verifiable audit trail + durable revocation (issues #126, #127). + +Runs fully offline. Demonstrates: + +1. Recording invocations to a SQLite-backed, hash-chained trace store. +2. Verifying chain integrity — and detecting tampering. +3. Revocation that survives a fresh token-provider instance (as it would across + a process restart) via a SQLite revocation store. + +Run: ``python examples/persistent_audit_demo.py`` +""" + +from __future__ import annotations + +import asyncio +import sqlite3 +import tempfile +from pathlib import Path + +from weaver_kernel import ( + Capability, + CapabilityRegistry, + CapabilityRequest, + HMACTokenProvider, + InMemoryDriver, + Kernel, + Principal, + SafetyClass, + StaticRouter, +) +from weaver_kernel.errors import TokenRevoked +from weaver_kernel.stores import SQLiteRevocationStore, SQLiteTraceStore + +SECRET = "persistent-audit-demo-secret" # demo only — set WEAVER_KERNEL_SECRET in production + + +def _build_kernel(trace_db: Path, provider: HMACTokenProvider) -> Kernel: + registry = CapabilityRegistry() + registry.register( + Capability( + capability_id="billing.list_invoices", + name="List Invoices", + description="List invoices for a customer", + safety_class=SafetyClass.READ, + ) + ) + driver = InMemoryDriver(driver_id="billing") + driver.register_handler("billing.list_invoices", lambda ctx: [{"id": 1, "amount": 42}]) + kernel = Kernel( + registry=registry, + token_provider=provider, + router=StaticRouter(routes={"billing.list_invoices": ["billing"]}), + trace_store=SQLiteTraceStore(trace_db, secret=SECRET), + ) + kernel.register_driver(driver) + return kernel + + +async def _main() -> None: + workdir = Path(tempfile.mkdtemp(prefix="weaver-audit-demo-")) + trace_db = workdir / "audit.db" + revoke_db = workdir / "revoked.db" + + provider = HMACTokenProvider(secret=SECRET, revocation_store=SQLiteRevocationStore(revoke_db)) + kernel = _build_kernel(trace_db, provider) + principal = Principal(principal_id="u1", roles=["reader"]) + request = CapabilityRequest(capability_id="billing.list_invoices", goal="list invoices") + + # 1. Record a couple of invocations into the durable, hash-chained store. + for _ in range(2): + token = kernel.get_token(request, principal, justification="month-end review") + await kernel.invoke( + token, principal=principal, args={"operation": "billing.list_invoices"} + ) + + store = SQLiteTraceStore(trace_db, secret=SECRET) + print(f"recorded {len(store.list_all())} traces") + print(f"verify (intact): {store.verify_chain().detail}") + + # 2. Tamper directly with the database, then re-verify. + conn = sqlite3.connect(str(trace_db)) + conn.execute('UPDATE traces SET payload = \'{"action_id":"x"}\' WHERE seq = 0') + conn.commit() + conn.close() + tampered = SQLiteTraceStore(trace_db, secret=SECRET).verify_chain() + print(f"verify (tampered): ok={tampered.ok}, first_bad_seq={tampered.first_bad_seq}") + + # 3. Durable revocation: revoke a token, then prove a *fresh* provider + # (as after a restart) still honours the revocation from disk. + token = kernel.get_token(request, principal, justification="will be revoked") + provider.revoke(token.token_id) + fresh = HMACTokenProvider(secret=SECRET, revocation_store=SQLiteRevocationStore(revoke_db)) + try: + fresh.verify( + token, expected_principal_id="u1", expected_capability_id="billing.list_invoices" + ) + print("ERROR: revoked token verified") + except TokenRevoked: + print("revoked token rejected by a fresh provider (survives restart)") + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/pyproject.toml b/pyproject.toml index a282dfe..e2e5e27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "weaver-kernel" -version = "0.10.0" +version = "0.11.0" description = "Capability-based security kernel for AI agents operating in large tool ecosystems" readme = "README.md" license = { file = "LICENSE" } @@ -40,6 +40,9 @@ dependencies = [ "pydantic>=2", ] +[project.scripts] +weaver-kernel = "weaver_kernel.cli:main" + [project.urls] Homepage = "https://github.com/dgenio/agent-kernel" Repository = "https://github.com/dgenio/agent-kernel" diff --git a/src/weaver_kernel/__init__.py b/src/weaver_kernel/__init__.py index 9f358f2..48f8ed8 100644 --- a/src/weaver_kernel/__init__.py +++ b/src/weaver_kernel/__init__.py @@ -28,6 +28,15 @@ from weaver_kernel import HandleStore, TraceStore from weaver_kernel import export_action_trace, export_action_traces +Durable persistence & verifiable audit log:: + + from weaver_kernel import SQLiteTraceStore, JsonlTraceStore + from weaver_kernel import SQLiteRevocationStore, InMemoryRevocationStore + from weaver_kernel import verify_chain, ChainVerificationResult, TraceRecord + from weaver_kernel import ( + TraceStoreProtocol, RevocationStoreProtocol, HandleStoreProtocol, + ) + LLM tool-format adapters:: from weaver_kernel import OpenAIMiddleware, AnthropicMiddleware @@ -140,6 +149,18 @@ from .policy_reasons import AllowReason, DenialReason from .registry import CapabilityRegistry from .router import StaticRouter +from .stores import ( + ChainVerificationResult, + HandleStoreProtocol, + InMemoryRevocationStore, + JsonlTraceStore, + RevocationStoreProtocol, + SQLiteRevocationStore, + SQLiteTraceStore, + TraceRecord, + TraceStoreProtocol, + verify_chain, +) from .tokens import CapabilityToken, HMACTokenProvider from .trace import ( TRACE_EXPORT_SCHEMA, @@ -257,6 +278,17 @@ # stores "HandleStore", "TraceStore", + # durable persistence & verifiable audit log (issues #126, #127) + "TraceStoreProtocol", + "RevocationStoreProtocol", + "HandleStoreProtocol", + "InMemoryRevocationStore", + "JsonlTraceStore", + "SQLiteTraceStore", + "SQLiteRevocationStore", + "ChainVerificationResult", + "TraceRecord", + "verify_chain", # trace export (issue #94) "TRACE_EXPORT_SCHEMA", "TRACE_EXPORT_VERSION", diff --git a/src/weaver_kernel/_secrets.py b/src/weaver_kernel/_secrets.py new file mode 100644 index 0000000..3a81ca7 --- /dev/null +++ b/src/weaver_kernel/_secrets.py @@ -0,0 +1,64 @@ +"""HMAC secret loading, shared by token signing and audit-log hashing. + +Kept in its own module so both :mod:`weaver_kernel.tokens` and +:mod:`weaver_kernel.stores.audit_chain` can resolve the signing secret the same +way without importing one another (which would create an import cycle). + +Security: the resolved secret is never logged. Only the *absence* of an +explicit secret is reported, via a one-time warning when the dev fallback is +generated. +""" + +from __future__ import annotations + +import logging +import os +import secrets +import threading + +logger = logging.getLogger(__name__) + +SECRET_ENV_VAR = "WEAVER_KERNEL_SECRET" +"""Environment variable holding the HMAC secret used for tokens and audit chains.""" + +_DEV_SECRET: str | None = None +_DEV_SECRET_LOCK = threading.Lock() + + +def _get_secret() -> str: + """Return the HMAC secret from the environment or a generated dev fallback. + + Thread-safe: a :class:`threading.Lock` ensures only one thread generates the + fallback secret, and the warning is emitted once. + """ + global _DEV_SECRET + secret = os.environ.get(SECRET_ENV_VAR) + if secret: + return secret + with _DEV_SECRET_LOCK: + if _DEV_SECRET is None: + _DEV_SECRET = secrets.token_hex(32) + logger.warning( + "%s is not set. Using a random development secret — tokens and " + "audit-chain signatures will not survive restarts. Set %s in production.", + SECRET_ENV_VAR, + SECRET_ENV_VAR, + ) + return _DEV_SECRET + + +def resolve_hmac_secret(explicit: str | None = None) -> str: + """Resolve the HMAC secret to use. + + Args: + explicit: A secret supplied directly by the caller. When non-empty it + takes precedence over the environment and dev fallback. + + Returns: + The explicit secret if given, else ``WEAVER_KERNEL_SECRET`` from the + environment, else a process-lived random development secret (with a + one-time warning). + """ + if explicit: + return explicit + return _get_secret() diff --git a/src/weaver_kernel/cli/__init__.py b/src/weaver_kernel/cli/__init__.py new file mode 100644 index 0000000..d837b53 --- /dev/null +++ b/src/weaver_kernel/cli/__init__.py @@ -0,0 +1,51 @@ +"""``weaver-kernel`` command-line entry point. + +Subcommands: + +* ``audit`` — inspect, filter, verify, and export persisted action traces (#147). +* ``doctor`` — preflight-check the local environment and self-test vectors (#124). + +Both are stdlib-only (argparse); see ``docs/cli.md``. +""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence + +from ..errors import AgentKernelError +from ._audit import build_audit_parser +from ._doctor import build_doctor_parser + + +def build_parser() -> argparse.ArgumentParser: + """Construct the top-level ``weaver-kernel`` argument parser.""" + parser = argparse.ArgumentParser( + prog="weaver-kernel", + description="Capability-based security kernel — operator CLI.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + build_audit_parser(subparsers) + build_doctor_parser(subparsers) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Entry point. Returns a process exit code. + + Kernel errors (e.g. an unknown action id) are reported on stderr with a + non-zero exit, never as an uncaught traceback. + """ + parser = build_parser() + args = parser.parse_args(argv) + try: + result = args.handler(args) + return int(result) + except AgentKernelError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/weaver_kernel/cli/_audit.py b/src/weaver_kernel/cli/_audit.py new file mode 100644 index 0000000..a79baeb --- /dev/null +++ b/src/weaver_kernel/cli/_audit.py @@ -0,0 +1,216 @@ +"""``weaver-kernel audit`` — inspect, filter, and verify action traces (issue #147). + +Operates on a persisted trace store (:class:`SQLiteTraceStore` or +:class:`JsonlTraceStore`). Output is redaction-safe by construction: the CLI +renders only what an :class:`~weaver_kernel.models.ActionTrace` already holds — +no flag surfaces raw driver output. + +Note: + Traces describe **authorised** invocations only: a denied request never + produces an :class:`ActionTrace` (policy gates before invocation, per I-02). + Filtering is therefore by ``--status succeeded|failed``, not by an + allow/deny/ask decision, which the trace store does not record. +""" + +from __future__ import annotations + +import argparse +import datetime +import json +from pathlib import Path +from typing import Protocol + +from ..models import ActionTrace +from ..stores import TraceStoreProtocol +from ..stores._trace_codec import encode_trace +from ..stores.audit_chain import ChainVerificationResult, TraceRecord +from ..stores.jsonl import JsonlTraceStore +from ..stores.sqlite import SQLiteTraceStore + + +class _VerifiableTraceStore(TraceStoreProtocol, Protocol): + """A persisted trace store that also exposes the audit chain.""" + + def list_records(self) -> list[TraceRecord]: ... + + def verify_chain(self) -> ChainVerificationResult: ... + + +def open_trace_store( + path: str, fmt: str | None, *, secret: str | None = None +) -> _VerifiableTraceStore: + """Open a persisted trace store, inferring the format from the path suffix.""" + resolved = fmt or ("jsonl" if path.endswith(".jsonl") else "sqlite") + if resolved == "jsonl": + return JsonlTraceStore(path, secret=secret) + return SQLiteTraceStore(path, secret=secret) + + +def _parse_dt(value: str) -> datetime.datetime: + dt = datetime.datetime.fromisoformat(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + return dt + + +def _matches( + trace: ActionTrace, + *, + principal: str | None, + capability: str | None, + status: str | None, + since: datetime.datetime | None, + until: datetime.datetime | None, +) -> bool: + if principal is not None and trace.principal_id != principal: + return False + if capability is not None and trace.capability_id != capability: + return False + if status is not None: + trace_status = "failed" if trace.error is not None else "succeeded" + if trace_status != status: + return False + if since is not None and trace.invoked_at < since: + return False + return not (until is not None and trace.invoked_at >= until) + + +def _filter_traces(store: TraceStoreProtocol, args: argparse.Namespace) -> list[ActionTrace]: + since = _parse_dt(args.since) if args.since else None + until = _parse_dt(args.until) if args.until else None + traces = [ + t + for t in store.list_all() + if _matches( + t, + principal=args.principal, + capability=args.capability, + status=args.status, + since=since, + until=until, + ) + ] + if args.limit is not None: + traces = traces[: args.limit] + return traces + + +def cmd_list(args: argparse.Namespace) -> int: + """Handle ``audit list``.""" + store = open_trace_store(args.store, args.format, secret=args.secret) + traces = _filter_traces(store, args) + if args.json: + print(json.dumps([encode_trace(t) for t in traces], indent=2)) + return 0 + if not traces: + print("No traces matched.") + return 0 + header = f"{'ACTION_ID':<38} {'CAPABILITY':<28} {'PRINCIPAL':<18} {'STATUS':<9} INVOKED_AT" + print(header) + print("-" * len(header)) + for t in traces: + status = "failed" if t.error is not None else "succeeded" + print( + f"{t.action_id:<38} {t.capability_id:<28} {t.principal_id:<18} " + f"{status:<9} {t.invoked_at.isoformat()}" + ) + return 0 + + +def cmd_show(args: argparse.Namespace) -> int: + """Handle ``audit show ACTION_ID``.""" + store = open_trace_store(args.store, args.format, secret=args.secret) + trace = store.get(args.action_id) # raises AgentKernelError if unknown + print(json.dumps(encode_trace(trace), indent=2)) + return 0 + + +def cmd_verify(args: argparse.Namespace) -> int: + """Handle ``audit verify``. Exit non-zero if the chain does not verify.""" + store = open_trace_store(args.store, args.format, secret=args.secret) + result = store.verify_chain() + if args.json: + print( + json.dumps( + { + "ok": result.ok, + "records_checked": result.records_checked, + "first_bad_seq": result.first_bad_seq, + "detail": result.detail, + } + ) + ) + else: + print(("OK: " if result.ok else "TAMPER DETECTED: ") + result.detail) + return 0 if result.ok else 1 + + +def cmd_export(args: argparse.Namespace) -> int: + """Handle ``audit export`` — emit filtered traces as JSONL (one per line).""" + store = open_trace_store(args.store, args.format, secret=args.secret) + traces = _filter_traces(store, args) + lines = [json.dumps(encode_trace(t)) for t in traces] + payload = "\n".join(lines) + if args.out: + Path(args.out).write_text(payload + ("\n" if payload else ""), encoding="utf-8") + print(f"Wrote {len(traces)} trace(s) to {args.out}") + else: + print(payload) + return 0 + + +def add_common_store_arguments(parser: argparse.ArgumentParser) -> None: + """Attach the store-selection flags shared by every audit subcommand.""" + parser.add_argument( + "--store", required=True, help="Path to the trace store (SQLite or .jsonl)." + ) + parser.add_argument( + "--format", + choices=["sqlite", "jsonl"], + help="Store format (default: inferred from the path suffix).", + ) + parser.add_argument( + "--secret", + help="HMAC secret for chain verification (default: WEAVER_KERNEL_SECRET).", + ) + parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") + + +def add_filter_arguments(parser: argparse.ArgumentParser) -> None: + """Attach the shared list/export filter flags to *parser*.""" + parser.add_argument("--principal", help="Filter by principal id.") + parser.add_argument("--capability", help="Filter by capability id.") + parser.add_argument( + "--status", + choices=["succeeded", "failed"], + help="Filter by outcome (denials are not traced; see --help).", + ) + parser.add_argument("--since", help="Only traces at/after this ISO-8601 timestamp.") + parser.add_argument("--until", help="Only traces strictly before this ISO-8601 timestamp.") + parser.add_argument("--limit", type=int, help="Cap the number of traces returned.") + + +def build_audit_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] + """Register the ``audit`` subcommand tree on *subparsers*.""" + audit = subparsers.add_parser("audit", help="Inspect and verify persisted action traces.") + audit_sub = audit.add_subparsers(dest="audit_command", required=True) + + p_list = audit_sub.add_parser("list", help="List/filter traces as a table.") + add_common_store_arguments(p_list) + add_filter_arguments(p_list) + p_list.set_defaults(handler=cmd_list) + + p_show = audit_sub.add_parser("show", help="Show one trace in full.") + add_common_store_arguments(p_show) + p_show.add_argument("action_id", help="The action id to display.") + p_show.set_defaults(handler=cmd_show) + + p_verify = audit_sub.add_parser("verify", help="Verify the hash chain integrity.") + add_common_store_arguments(p_verify) + p_verify.set_defaults(handler=cmd_verify) + + p_export = audit_sub.add_parser("export", help="Export filtered traces as JSONL.") + add_common_store_arguments(p_export) + add_filter_arguments(p_export) + p_export.add_argument("--out", help="Write to this file instead of stdout.") + p_export.set_defaults(handler=cmd_export) diff --git a/src/weaver_kernel/cli/_doctor.py b/src/weaver_kernel/cli/_doctor.py new file mode 100644 index 0000000..2d02999 --- /dev/null +++ b/src/weaver_kernel/cli/_doctor.py @@ -0,0 +1,165 @@ +"""``weaver-kernel doctor`` — preflight checks for a local setup (issue #124). + +Verifies environment, optional extras, and self-test vectors for token +signing/verification and the audit chain. Exits non-zero when a check *errors* +(broken setup); an insecure demo-only configuration (no ``WEAVER_KERNEL_SECRET``) +is reported as a warning, not a failure. + +Never prints secret material — only whether a secret is configured. +""" + +from __future__ import annotations + +import argparse +import datetime +import importlib.util +import json +import logging +import os +import sys +from dataclasses import dataclass + +from .._secrets import SECRET_ENV_VAR +from ..models import ActionTrace +from ..stores.audit_chain import build_record, verify_chain +from ..tokens import CapabilityToken, HMACTokenProvider + +OK = "ok" +WARN = "warn" +ERROR = "error" + + +@dataclass(slots=True) +class Check: + """A single doctor check outcome.""" + + name: str + status: str + detail: str + + +def _check_python() -> Check: + v = sys.version_info + detail = f"{v.major}.{v.minor}.{v.micro}" + status = OK if v >= (3, 10) else ERROR + return Check("python", status, detail) + + +def _check_secret() -> Check: + if os.environ.get(SECRET_ENV_VAR): + return Check("secret", OK, f"{SECRET_ENV_VAR} is set.") + return Check( + "secret", + WARN, + f"{SECRET_ENV_VAR} is not set — using an ephemeral dev secret " + "(tokens/audit signatures do not survive restarts). Set it in production.", + ) + + +def _check_extra(name: str, module: str) -> Check: + available = importlib.util.find_spec(module) is not None + return Check( + f"extra:{name}", + OK if available else WARN, + f"{module} {'available' if available else 'not installed'}.", + ) + + +def _check_token_vector() -> Check: + """Issue, verify, then tamper with a token to prove signing works.""" + provider = HMACTokenProvider(secret="weaver-kernel-doctor-test-vector") + token = provider.issue("doctor.cap", "doctor-principal") + try: + provider.verify( + token, + expected_principal_id="doctor-principal", + expected_capability_id="doctor.cap", + ) + except Exception as exc: # pragma: no cover - would indicate a broken build + return Check("token_vector", ERROR, f"verify() rejected a valid token: {exc}") + tampered = CapabilityToken( + token_id=token.token_id, + capability_id=token.capability_id, + principal_id=token.principal_id, + issued_at=token.issued_at, + expires_at=token.expires_at, + constraints=token.constraints, + audit_id=token.audit_id, + signature="0" * len(token.signature), + ) + # The tamper check deliberately fails verification; silence the expected + # WARNING so the doctor output stays clean. + tok_logger = logging.getLogger("weaver_kernel.tokens") + previous_level = tok_logger.level + tok_logger.setLevel(logging.ERROR) + try: + provider.verify( + tampered, + expected_principal_id="doctor-principal", + expected_capability_id="doctor.cap", + ) + except Exception: + return Check("token_vector", OK, "sign/verify and tamper-detection pass.") + finally: + tok_logger.setLevel(previous_level) + return Check("token_vector", ERROR, "verify() accepted a tampered signature.") + + +def _check_audit_chain_vector() -> Check: + """Build a tiny chain, mutate it, and confirm verification flips.""" + secret = "weaver-kernel-doctor-test-vector" + now = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + trace = ActionTrace( + action_id="doctor-1", + capability_id="doctor.cap", + principal_id="doctor-principal", + token_id="doctor-tok", + invoked_at=now, + args={}, + response_mode="summary", + driver_id="memory", + ) + from ..stores._trace_codec import encode_trace + from ..stores.audit_chain import GENESIS_HASH + + record = build_record(0, GENESIS_HASH, encode_trace(trace), secret=secret) + if not verify_chain([record], secret=secret).ok: + return Check("audit_chain", ERROR, "a valid single-record chain failed to verify.") + record.trace["principal_id"] = "tampered" + if verify_chain([record], secret=secret).ok: + return Check("audit_chain", ERROR, "a mutated chain still verified.") + return Check("audit_chain", OK, "hash chain detects mutation.") + + +def run_checks() -> list[Check]: + """Run every doctor check and return the outcomes.""" + return [ + _check_python(), + _check_secret(), + _check_extra("policy", "yaml"), + _check_extra("mcp", "mcp"), + _check_extra("otel", "opentelemetry"), + _check_token_vector(), + _check_audit_chain_vector(), + ] + + +def cmd_doctor(args: argparse.Namespace) -> int: + """Handle ``doctor``. Returns non-zero if any check errored.""" + checks = run_checks() + if args.json: + print( + json.dumps([{"name": c.name, "status": c.status, "detail": c.detail} for c in checks]) + ) + else: + symbols = {OK: "✓", WARN: "!", ERROR: "✗"} + for c in checks: + print(f" {symbols[c.status]} {c.name:<16} {c.detail}") + return 1 if any(c.status == ERROR for c in checks) else 0 + + +def build_doctor_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] + """Register the ``doctor`` subcommand on *subparsers*.""" + doctor = subparsers.add_parser("doctor", help="Preflight-check the local setup.") + doctor.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") + doctor.set_defaults(handler=cmd_doctor) diff --git a/src/weaver_kernel/handles.py b/src/weaver_kernel/handles.py index 7530e2b..8dcdfb2 100644 --- a/src/weaver_kernel/handles.py +++ b/src/weaver_kernel/handles.py @@ -256,6 +256,11 @@ def expand( f"Handle expand 'offset' must be an integer, got {query.get('offset')!r}.", reason_code=DenialReason.INVALID_CONSTRAINT, ) from exc + if offset < 0: + raise HandleConstraintViolation( + f"Handle expand 'offset' must be non-negative, got {offset}.", + reason_code=DenialReason.INVALID_CONSTRAINT, + ) requested_limit_raw = query.get("limit") requested_limit: int | None if requested_limit_raw is None: @@ -268,6 +273,14 @@ def expand( f"Handle expand 'limit' must be an integer, got {requested_limit_raw!r}.", reason_code=DenialReason.INVALID_CONSTRAINT, ) from exc + # A negative limit would slice as rows[offset:offset+limit] and could + # return rows in excess of a (possibly zero) grant cap — reject it + # rather than silently bypassing max_rows. + if requested_limit < 0: + raise HandleConstraintViolation( + f"Handle expand 'limit' must be non-negative, got {requested_limit}.", + reason_code=DenialReason.INVALID_CONSTRAINT, + ) limit = len(rows) if requested_limit is None else requested_limit if isinstance(granted_max_rows, int) and granted_max_rows >= 0: diff --git a/src/weaver_kernel/kernel/__init__.py b/src/weaver_kernel/kernel/__init__.py index 6643ccb..c06f063 100644 --- a/src/weaver_kernel/kernel/__init__.py +++ b/src/weaver_kernel/kernel/__init__.py @@ -37,6 +37,7 @@ from ..policy import DefaultPolicyEngine, PolicyEngine from ..registry import CapabilityRegistry from ..router import Router, StaticRouter +from ..stores import TraceStoreProtocol from ..tokens import CapabilityToken, HMACTokenProvider, TokenProvider from ..trace import TraceStore from ._dry_run import build_dry_run_result @@ -79,7 +80,7 @@ def __init__( router: Router | None = None, firewall: Firewall | None = None, handle_store: HandleStore | None = None, - trace_store: TraceStore | None = None, + trace_store: TraceStoreProtocol | None = None, budget_manager: BudgetManager | None = None, kernel_id: str = "agent-kernel", ) -> None: @@ -89,7 +90,7 @@ def __init__( self._router: Router = router or StaticRouter() self._firewall = firewall or Firewall() self._handle_store = handle_store or HandleStore() - self._trace_store = trace_store or TraceStore() + self._trace_store: TraceStoreProtocol = trace_store or TraceStore() self._budget_manager = budget_manager self._drivers: dict[str, Driver] = {} self._kernel_id = kernel_id @@ -450,7 +451,7 @@ def _handles(self) -> HandleStore: return self._handle_store @property - def _traces(self) -> TraceStore: + def _traces(self) -> TraceStoreProtocol: return self._trace_store diff --git a/src/weaver_kernel/kernel/_invoke.py b/src/weaver_kernel/kernel/_invoke.py index dd546e8..3abfcf5 100644 --- a/src/weaver_kernel/kernel/_invoke.py +++ b/src/weaver_kernel/kernel/_invoke.py @@ -35,8 +35,8 @@ ResponseMode, RoutePlan, ) +from ..stores import TraceStoreProtocol from ..tokens import CapabilityToken -from ..trace import TraceStore if TYPE_CHECKING: # pragma: no cover from . import Kernel @@ -151,7 +151,7 @@ def record_failure_trace( args: dict[str, Any], response_mode: ResponseMode, error_message: str, - trace_store: TraceStore, + trace_store: TraceStoreProtocol, sensitivity: SensitivityTag = SensitivityTag.NONE, ) -> None: """Persist an :class:`ActionTrace` for a run where no driver succeeded.""" @@ -182,7 +182,7 @@ def record_success_trace( driver_id: str, handle_id: str | None, result_summary: dict[str, Any] | None, - trace_store: TraceStore, + trace_store: TraceStoreProtocol, sensitivity: SensitivityTag = SensitivityTag.NONE, ) -> None: """Persist an :class:`ActionTrace` for a successful invocation.""" diff --git a/src/weaver_kernel/stores/__init__.py b/src/weaver_kernel/stores/__init__.py new file mode 100644 index 0000000..6cf3740 --- /dev/null +++ b/src/weaver_kernel/stores/__init__.py @@ -0,0 +1,59 @@ +"""Pluggable persistence backends for the kernel's stateful stores (issue #126). + +The in-memory stores remain the defaults. Import a durable backend and inject it +via constructor: + +.. code-block:: python + + from weaver_kernel import Kernel, HMACTokenProvider + from weaver_kernel.stores import SQLiteTraceStore, SQLiteRevocationStore + + traces = SQLiteTraceStore("audit.db") + tokens = HMACTokenProvider(revocation_store=SQLiteRevocationStore("revoked.db")) + kernel = Kernel(registry, token_provider=tokens, trace_store=traces) + +See ``docs/architecture.md`` for backend selection and durability trade-offs. +""" + +from __future__ import annotations + +from ._protocols import ( + HandleStoreProtocol, + RevocationStoreProtocol, + TraceStoreProtocol, +) +from ._trace_codec import decode_trace, encode_trace +from .audit_chain import ( + CHAIN_VERSION, + GENESIS_HASH, + ChainVerificationResult, + TraceRecord, + build_record, + compute_record_hash, + verify_chain, +) +from .jsonl import JsonlTraceStore +from .memory import InMemoryRevocationStore +from .sqlite import SQLiteRevocationStore, SQLiteTraceStore + +__all__ = [ + # protocols + "TraceStoreProtocol", + "RevocationStoreProtocol", + "HandleStoreProtocol", + # audit chain (issue #127) + "CHAIN_VERSION", + "GENESIS_HASH", + "ChainVerificationResult", + "TraceRecord", + "build_record", + "compute_record_hash", + "verify_chain", + "decode_trace", + "encode_trace", + # backends + "InMemoryRevocationStore", + "JsonlTraceStore", + "SQLiteTraceStore", + "SQLiteRevocationStore", +] diff --git a/src/weaver_kernel/stores/_protocols.py b/src/weaver_kernel/stores/_protocols.py new file mode 100644 index 0000000..56bc344 --- /dev/null +++ b/src/weaver_kernel/stores/_protocols.py @@ -0,0 +1,84 @@ +"""Storage-backend protocols for the kernel's stateful stores. + +These mirror the existing protocol-based seams in the codebase +(:class:`~weaver_kernel.drivers.base.Driver`, +:class:`~weaver_kernel.tokens.TokenProvider`): the in-memory implementations +remain the defaults, and any object satisfying these protocols can be injected +into the :class:`~weaver_kernel.Kernel` / :class:`~weaver_kernel.HMACTokenProvider` +via constructor injection. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from ..models import ActionTrace + + +@runtime_checkable +class TraceStoreProtocol(Protocol): + """Interface for storing and retrieving :class:`ActionTrace` records. + + The in-memory :class:`~weaver_kernel.TraceStore` is the default + implementation; :class:`~weaver_kernel.stores.SQLiteTraceStore` and + :class:`~weaver_kernel.stores.JsonlTraceStore` add durability. + """ + + def record(self, trace: ActionTrace) -> None: + """Persist an action trace.""" + ... + + def get(self, action_id: str) -> ActionTrace: + """Return the trace for *action_id* or raise :class:`AgentKernelError`.""" + ... + + def list_all(self) -> list[ActionTrace]: + """Return all recorded traces in insertion order.""" + ... + + +@runtime_checkable +class RevocationStoreProtocol(Protocol): + """Interface for the token revocation list used by :class:`HMACTokenProvider`. + + Implementations must be safe to consult on the hot verification path + (:meth:`is_revoked`) and must honour I-02: a token revoked before + verification never validates afterwards. + """ + + def is_revoked(self, token_id: str) -> bool: + """Return whether *token_id* has been revoked.""" + ... + + def revoke(self, token_id: str) -> None: + """Revoke a single token. Idempotent.""" + ... + + def track(self, principal_id: str, token_id: str) -> None: + """Record that *token_id* was issued to *principal_id* (for ``revoke_all``).""" + ... + + def revoke_principal(self, principal_id: str) -> int: + """Revoke every tracked token for *principal_id*. + + Returns: + The count of tokens newly revoked by this call (excluding tokens + already revoked). + """ + ... + + +@runtime_checkable +class HandleStoreProtocol(Protocol): + """Interface for the full-result handle store. + + Note: + Only the in-memory :class:`~weaver_kernel.HandleStore` ships today. The + protocol is defined so a durable backend can be added later without a + breaking change; handles are short-lived, TTL-bounded result caches, so + durability is lower priority than the audit-trail stores. + """ + + def evict_expired(self) -> int: + """Drop all expired handles; return the number evicted.""" + ... diff --git a/src/weaver_kernel/stores/_trace_codec.py b/src/weaver_kernel/stores/_trace_codec.py new file mode 100644 index 0000000..526da5d --- /dev/null +++ b/src/weaver_kernel/stores/_trace_codec.py @@ -0,0 +1,50 @@ +"""Encode/decode :class:`ActionTrace` ↔ the persisted, redaction-safe payload. + +Encoding reuses :func:`~weaver_kernel.export_action_trace`, so a persisted +record contains exactly the stable export shape (issue #94) — nothing the +in-memory trace did not already hold. Decoding is its inverse; the export-only +``status`` and ``correction`` fields are derived/ignored on the way back. +""" + +from __future__ import annotations + +import datetime +from typing import Any + +from ..enums import SensitivityTag +from ..errors import AgentKernelError +from ..models import ActionTrace +from ..trace import export_action_trace + + +def encode_trace(trace: ActionTrace) -> dict[str, Any]: + """Return the redaction-safe, JSON-serialisable payload for *trace*.""" + return export_action_trace(trace) + + +def decode_trace(payload: dict[str, Any]) -> ActionTrace: + """Reconstruct an :class:`ActionTrace` from a persisted payload. + + Raises: + AgentKernelError: If the payload is missing a required field — surfaced + as a kernel error rather than a bare ``KeyError`` (see AGENTS.md). + """ + try: + return ActionTrace( + action_id=payload["action_id"], + capability_id=payload["capability_id"], + principal_id=payload["principal_id"], + token_id=payload["token_id"], + invoked_at=datetime.datetime.fromisoformat(payload["invoked_at"]), + args=payload["args"], + response_mode=payload["response_mode"], + driver_id=payload["driver_id"], + handle_id=payload.get("handle_id"), + error=payload.get("error"), + result_summary=payload.get("result_summary"), + sensitivity=SensitivityTag(payload.get("sensitivity", "NONE")), + ) + except KeyError as exc: + raise AgentKernelError( + f"Persisted trace payload is missing required field {exc}." + ) from exc diff --git a/src/weaver_kernel/stores/audit_chain.py b/src/weaver_kernel/stores/audit_chain.py new file mode 100644 index 0000000..e30ab98 --- /dev/null +++ b/src/weaver_kernel/stores/audit_chain.py @@ -0,0 +1,173 @@ +"""Hash-chained, verifiable audit-log envelope for persisted traces (issue #127). + +Each persisted :class:`~weaver_kernel.models.ActionTrace` is wrapped in a +:class:`TraceRecord` that carries the hash of the previous record. The chain is +keyed by the same HMAC secret used for token signing +(:data:`~weaver_kernel._secrets.SECRET_ENV_VAR`), so any insertion, deletion, +mutation, or reordering of records is detectable by :func:`verify_chain`. + +Integrity model (honest scope): this gives **tamper-evidence** against post-hoc +edits by anyone who does not hold the secret. It is **not** non-repudiation — a +host that controls ``WEAVER_KERNEL_SECRET`` can forge a self-consistent chain. +See ``docs/security.md``. + +The chaining envelope is intentionally separate from ``ActionTrace`` semantics: +``prev_hash``/``record_hash``/``seq`` live only on the persisted record, never on +the in-memory trace. The wrapped ``trace`` payload is the redaction-safe +:func:`~weaver_kernel.export_action_trace` shape, so chaining adds no field the +trace did not already hold and cannot widen the I-01 boundary. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any + +CHAIN_VERSION = "1" +"""Bumped only on a breaking change to the record-hash computation.""" + +GENESIS_HASH = "0" * 64 +"""``prev_hash`` of the first record in a chain (and of the first record after a +pruning checkpoint when no checkpoint hash is carried).""" + + +def canonical_json(obj: Any) -> str: + """Serialise *obj* deterministically (sorted keys, no whitespace). + + Determinism is required so a record hashes identically across processes and + Python versions — consistent with the repo's "no randomness in matching" + rule. + """ + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def compute_record_hash( + seq: int, + prev_hash: str, + trace: dict[str, Any], + *, + secret: str, +) -> str: + """Return the HMAC-SHA256 hex digest binding *seq*, *prev_hash*, and *trace*. + + Args: + seq: Monotonic 0-based position of the record in the chain. + prev_hash: ``record_hash`` of the preceding record (or the genesis / + checkpoint hash for the first record). + trace: The redaction-safe exported-trace payload. + secret: HMAC key. + + Returns: + A 64-character lowercase hex digest. + """ + message = canonical_json({"seq": seq, "prev_hash": prev_hash, "trace": trace}) + return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest() + + +@dataclass(slots=True) +class TraceRecord: + """A persisted trace plus its position and hash linkage in the audit chain.""" + + seq: int + prev_hash: str + record_hash: str + trace: dict[str, Any] + + +def build_record( + seq: int, + prev_hash: str, + trace: dict[str, Any], + *, + secret: str, +) -> TraceRecord: + """Construct the next :class:`TraceRecord` with a freshly computed hash.""" + record_hash = compute_record_hash(seq, prev_hash, trace, secret=secret) + return TraceRecord(seq=seq, prev_hash=prev_hash, record_hash=record_hash, trace=trace) + + +@dataclass(slots=True) +class ChainVerificationResult: + """Outcome of :func:`verify_chain`.""" + + ok: bool + """``True`` iff every record links and hashes correctly.""" + + records_checked: int + """Number of records examined before stopping.""" + + first_bad_seq: int | None + """``seq`` of the first divergent record, or ``None`` when ``ok`` is True.""" + + detail: str + """Human-readable description of the result (or the first failure).""" + + +def verify_chain( + records: Sequence[TraceRecord], + *, + secret: str, + genesis_prev_hash: str = GENESIS_HASH, +) -> ChainVerificationResult: + """Verify the integrity of an ordered sequence of trace records. + + Detects mutation (recomputed hash differs), insertion/deletion/reordering + (broken ``prev_hash`` linkage or non-contiguous ``seq``), and a wrong secret + (every hash diverges). Records must be supplied in ascending ``seq`` order. + + Args: + records: The chain to verify, ordered by ``seq``. + secret: The HMAC key the records were written with. + genesis_prev_hash: Expected ``prev_hash`` of the first record. Defaults + to :data:`GENESIS_HASH`; a pruned store passes its checkpoint hash so + the retained suffix still verifies. + + Returns: + A :class:`ChainVerificationResult`. + """ + expected_prev = genesis_prev_hash + expected_seq = records[0].seq if records else 0 + for checked, record in enumerate(records, start=1): + if record.seq != expected_seq: + return ChainVerificationResult( + ok=False, + records_checked=checked, + first_bad_seq=record.seq, + detail=( + f"Non-contiguous sequence at position {checked}: expected seq " + f"{expected_seq}, found {record.seq} (insertion, deletion, or reorder)." + ), + ) + if not hmac.compare_digest(record.prev_hash, expected_prev): + return ChainVerificationResult( + ok=False, + records_checked=checked, + first_bad_seq=record.seq, + detail=( + f"Broken link at seq {record.seq}: prev_hash does not match the " + "preceding record's hash (insertion, deletion, or reorder)." + ), + ) + recomputed = compute_record_hash(record.seq, record.prev_hash, record.trace, secret=secret) + if not hmac.compare_digest(recomputed, record.record_hash): + return ChainVerificationResult( + ok=False, + records_checked=checked, + first_bad_seq=record.seq, + detail=( + f"Tampered record at seq {record.seq}: content does not match its " + "stored hash (mutation, or wrong secret)." + ), + ) + expected_prev = record.record_hash + expected_seq = record.seq + 1 + return ChainVerificationResult( + ok=True, + records_checked=len(records), + first_bad_seq=None, + detail=f"Verified {len(records)} record(s).", + ) diff --git a/src/weaver_kernel/stores/jsonl.py b/src/weaver_kernel/stores/jsonl.py new file mode 100644 index 0000000..060d7c6 --- /dev/null +++ b/src/weaver_kernel/stores/jsonl.py @@ -0,0 +1,102 @@ +"""Append-only JSONL trace store (issue #126), hash-chained (#127). + +One JSON object per line — ``{"seq", "prev_hash", "record_hash", "trace"}`` — +so the file is safe to ship to a log collector and replay-loadable. The chain +links exactly as in :class:`~weaver_kernel.stores.SQLiteTraceStore`; verification +uses the genesis hash (JSONL is append-only and does not prune — rotate the file +externally if needed). +""" + +from __future__ import annotations + +import json +import threading +from pathlib import Path + +from .._secrets import resolve_hmac_secret +from ..errors import AgentKernelError +from ..models import ActionTrace +from ._trace_codec import decode_trace, encode_trace +from .audit_chain import ( + GENESIS_HASH, + ChainVerificationResult, + TraceRecord, + build_record, + verify_chain, +) + + +class JsonlTraceStore: + """Durable append-only :class:`TraceStoreProtocol` backend.""" + + def __init__(self, path: str | Path, *, secret: str | None = None) -> None: + self._secret = resolve_hmac_secret(secret) + self._path = Path(path) + self._lock = threading.Lock() + # Resume the chain from the file's current head, if any. + self._next_seq = 0 + self._last_hash = GENESIS_HASH + for record in self._iter_records(): + self._next_seq = record.seq + 1 + self._last_hash = record.record_hash + + def _iter_records(self) -> list[TraceRecord]: + if not self._path.exists(): + return [] + records: list[TraceRecord] = [] + with self._path.open(encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + obj = json.loads(line) + records.append( + TraceRecord( + seq=int(obj["seq"]), + prev_hash=str(obj["prev_hash"]), + record_hash=str(obj["record_hash"]), + trace=obj["trace"], + ) + ) + return records + + # ── TraceStoreProtocol ─────────────────────────────────────────────────── + + def record(self, trace: ActionTrace) -> None: + """Append *trace* as a new chained line.""" + payload = encode_trace(trace) + with self._lock: + record = build_record(self._next_seq, self._last_hash, payload, secret=self._secret) + line = json.dumps( + { + "seq": record.seq, + "prev_hash": record.prev_hash, + "record_hash": record.record_hash, + "trace": record.trace, + } + ) + with self._path.open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + self._next_seq = record.seq + 1 + self._last_hash = record.record_hash + + def get(self, action_id: str) -> ActionTrace: + """Return the trace for *action_id*.""" + for record in self._iter_records(): + if record.trace.get("action_id") == action_id: + return decode_trace(record.trace) + raise AgentKernelError(f"No action trace found for action_id='{action_id}'.") + + def list_all(self) -> list[ActionTrace]: + """Return all traces in append order.""" + return [decode_trace(record.trace) for record in self._iter_records()] + + # ── Audit chain (issue #127) ────────────────────────────────────────────── + + def list_records(self) -> list[TraceRecord]: + """Return the raw chain records in append order.""" + return self._iter_records() + + def verify_chain(self) -> ChainVerificationResult: + """Verify the full append-only chain from genesis.""" + return verify_chain(self._iter_records(), secret=self._secret) diff --git a/src/weaver_kernel/stores/memory.py b/src/weaver_kernel/stores/memory.py new file mode 100644 index 0000000..c469b5e --- /dev/null +++ b/src/weaver_kernel/stores/memory.py @@ -0,0 +1,53 @@ +"""In-memory revocation store — the default backend for :class:`HMACTokenProvider`. + +This is the behaviour previously inlined in ``HMACTokenProvider`` (a revoked-id +set plus a principal→token-ids index, guarded by a lock), extracted behind +:class:`~weaver_kernel.stores.RevocationStoreProtocol` so a durable backend can +be swapped in. Semantics are unchanged. +""" + +from __future__ import annotations + +import threading + + +class InMemoryRevocationStore: + """Process-local revocation list. State is lost on restart. + + For revocation that survives restarts and is shared across processes, use + :class:`~weaver_kernel.stores.SQLiteRevocationStore`. + """ + + def __init__(self) -> None: + self._revoked: set[str] = set() + self._principal_tokens: dict[str, set[str]] = {} + self._lock = threading.Lock() + + def is_revoked(self, token_id: str) -> bool: + """Return whether *token_id* has been revoked.""" + with self._lock: + return token_id in self._revoked + + def revoke(self, token_id: str) -> None: + """Revoke a single token. Idempotent.""" + with self._lock: + self._revoked.add(token_id) + + def track(self, principal_id: str, token_id: str) -> None: + """Record that *token_id* was issued to *principal_id*.""" + with self._lock: + self._principal_tokens.setdefault(principal_id, set()).add(token_id) + + def revoke_principal(self, principal_id: str) -> int: + """Revoke every tracked token for *principal_id*. + + Returns: + The count of tokens newly revoked by this call. + """ + with self._lock: + token_ids = self._principal_tokens.get(principal_id, set()) + newly_revoked = token_ids - self._revoked + self._revoked |= newly_revoked + # Drop the index entry; new tokens for this principal start fresh. + self._principal_tokens.pop(principal_id, None) + return len(newly_revoked) diff --git a/src/weaver_kernel/stores/sqlite.py b/src/weaver_kernel/stores/sqlite.py new file mode 100644 index 0000000..1148fe2 --- /dev/null +++ b/src/weaver_kernel/stores/sqlite.py @@ -0,0 +1,242 @@ +"""SQLite-backed durable stores (issue #126) with audit-chain integrity (#127). + +Uses the standard-library :mod:`sqlite3` only — no new runtime dependency. Each +store opens a single-file database; pass ``":memory:"`` for an ephemeral one +(useful in tests). + +* :class:`SQLiteTraceStore` — hash-chained, verifiable audit trail that survives + process restarts, plus retention pruning that preserves verifiability. +* :class:`SQLiteRevocationStore` — durable token revocation so ``revoke()`` / + ``revoke_all()`` outlive a restart and apply across processes. +""" + +from __future__ import annotations + +import datetime +import json +import sqlite3 +import threading +from pathlib import Path + +from .._secrets import resolve_hmac_secret +from ..errors import AgentKernelError +from ..models import ActionTrace +from ._trace_codec import decode_trace, encode_trace +from .audit_chain import ( + GENESIS_HASH, + ChainVerificationResult, + TraceRecord, + build_record, + verify_chain, +) + + +class SQLiteTraceStore: + """Durable, hash-chained :class:`TraceStoreProtocol` backend. + + The signing secret defaults to the shared ``WEAVER_KERNEL_SECRET`` path; a + store opened with a different secret than it was written with will fail + :meth:`verify_chain`. + """ + + def __init__(self, path: str | Path, *, secret: str | None = None) -> None: + self._secret = resolve_hmac_secret(secret) + self._lock = threading.Lock() + self._conn = sqlite3.connect(str(path), check_same_thread=False) + self._conn.execute( + "CREATE TABLE IF NOT EXISTS traces (" + "seq INTEGER PRIMARY KEY, " + "action_id TEXT UNIQUE NOT NULL, " + "prev_hash TEXT NOT NULL, " + "record_hash TEXT NOT NULL, " + "invoked_at TEXT NOT NULL, " + "payload TEXT NOT NULL)" + ) + self._conn.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)") + self._conn.commit() + + # ── Internal helpers ──────────────────────────────────────────────────── + + def _get_meta(self, key: str, default: str) -> str: + row = self._conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone() + return default if row is None else str(row[0]) + + def _set_meta(self, key: str, value: str) -> None: + self._conn.execute( + "INSERT INTO meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + + # ── TraceStoreProtocol ─────────────────────────────────────────────────── + + def record(self, trace: ActionTrace) -> None: + """Append *trace* to the chain, linked to the current head.""" + payload = encode_trace(trace) + with self._lock: + row = self._conn.execute( + "SELECT seq, record_hash FROM traces ORDER BY seq DESC LIMIT 1" + ).fetchone() + if row is not None: + next_seq = int(row[0]) + 1 + prev_hash = str(row[1]) + else: + next_seq = int(self._get_meta("checkpoint_next_seq", "0")) + prev_hash = self._get_meta("checkpoint_hash", GENESIS_HASH) + record = build_record(next_seq, prev_hash, payload, secret=self._secret) + self._conn.execute( + "INSERT INTO traces (seq, action_id, prev_hash, record_hash, invoked_at, payload) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + record.seq, + trace.action_id, + record.prev_hash, + record.record_hash, + payload["invoked_at"], + json.dumps(payload), + ), + ) + self._conn.commit() + + def get(self, action_id: str) -> ActionTrace: + """Return the trace for *action_id*.""" + row = self._conn.execute( + "SELECT payload FROM traces WHERE action_id = ?", (action_id,) + ).fetchone() + if row is None: + raise AgentKernelError(f"No action trace found for action_id='{action_id}'.") + return decode_trace(json.loads(row[0])) + + def list_all(self) -> list[ActionTrace]: + """Return all traces in chain order.""" + rows = self._conn.execute("SELECT payload FROM traces ORDER BY seq").fetchall() + return [decode_trace(json.loads(r[0])) for r in rows] + + # ── Audit chain (issue #127) ────────────────────────────────────────────── + + def list_records(self) -> list[TraceRecord]: + """Return the raw chain records in ``seq`` order (for export/verify).""" + rows = self._conn.execute( + "SELECT seq, prev_hash, record_hash, payload FROM traces ORDER BY seq" + ).fetchall() + return [ + TraceRecord( + seq=int(r[0]), + prev_hash=str(r[1]), + record_hash=str(r[2]), + trace=json.loads(r[3]), + ) + for r in rows + ] + + def verify_chain(self) -> ChainVerificationResult: + """Verify the full chain, honouring any pruning checkpoint.""" + genesis = self._get_meta("checkpoint_hash", GENESIS_HASH) + return verify_chain(self.list_records(), secret=self._secret, genesis_prev_hash=genesis) + + def prune(self, before: datetime.datetime) -> int: + """Delete records older than *before*, preserving suffix verifiability. + + The ``record_hash`` of the last pruned record is stored as the chain + checkpoint, so :meth:`verify_chain` still validates the retained suffix. + + Args: + before: Timezone-aware cutoff; records with ``invoked_at`` strictly + before this are removed. + + Returns: + The number of records pruned. + """ + cutoff = before.isoformat() + with self._lock: + doomed = self._conn.execute( + "SELECT seq, record_hash FROM traces WHERE invoked_at < ? ORDER BY seq", + (cutoff,), + ).fetchall() + if not doomed: + return 0 + last_seq, last_hash = int(doomed[-1][0]), str(doomed[-1][1]) + self._conn.execute("DELETE FROM traces WHERE invoked_at < ?", (cutoff,)) + self._set_meta("checkpoint_hash", last_hash) + self._set_meta("checkpoint_next_seq", str(last_seq + 1)) + self._conn.commit() + return len(doomed) + + def close(self) -> None: + """Close the underlying database connection.""" + self._conn.close() + + +class SQLiteRevocationStore: + """Durable :class:`RevocationStoreProtocol` backend. + + A token revoked here stays revoked after a process restart and is visible to + every process sharing the database file. + """ + + def __init__(self, path: str | Path) -> None: + self._lock = threading.Lock() + self._conn = sqlite3.connect(str(path), check_same_thread=False) + self._conn.execute("CREATE TABLE IF NOT EXISTS revoked (token_id TEXT PRIMARY KEY)") + self._conn.execute( + "CREATE TABLE IF NOT EXISTS principal_tokens (" + "principal_id TEXT NOT NULL, token_id TEXT NOT NULL, " + "PRIMARY KEY (principal_id, token_id))" + ) + self._conn.commit() + + def is_revoked(self, token_id: str) -> bool: + """Return whether *token_id* has been revoked.""" + row = self._conn.execute( + "SELECT 1 FROM revoked WHERE token_id = ?", (token_id,) + ).fetchone() + return row is not None + + def revoke(self, token_id: str) -> None: + """Revoke a single token. Idempotent.""" + with self._lock: + self._conn.execute("INSERT OR IGNORE INTO revoked (token_id) VALUES (?)", (token_id,)) + self._conn.commit() + + def track(self, principal_id: str, token_id: str) -> None: + """Record that *token_id* was issued to *principal_id*.""" + with self._lock: + self._conn.execute( + "INSERT OR IGNORE INTO principal_tokens (principal_id, token_id) VALUES (?, ?)", + (principal_id, token_id), + ) + self._conn.commit() + + def revoke_principal(self, principal_id: str) -> int: + """Revoke every tracked token for *principal_id*. + + Returns: + The count of tokens newly revoked by this call. + """ + with self._lock: + rows = self._conn.execute( + "SELECT token_id FROM principal_tokens WHERE principal_id = ?", + (principal_id,), + ).fetchall() + token_ids = {str(r[0]) for r in rows} + newly = { + tid + for tid in token_ids + if self._conn.execute( + "SELECT 1 FROM revoked WHERE token_id = ?", (tid,) + ).fetchone() + is None + } + self._conn.executemany( + "INSERT OR IGNORE INTO revoked (token_id) VALUES (?)", + [(tid,) for tid in newly], + ) + self._conn.execute( + "DELETE FROM principal_tokens WHERE principal_id = ?", (principal_id,) + ) + self._conn.commit() + return len(newly) + + def close(self) -> None: + """Close the underlying database connection.""" + self._conn.close() diff --git a/src/weaver_kernel/tokens.py b/src/weaver_kernel/tokens.py index 16b7a8b..f5cfa55 100644 --- a/src/weaver_kernel/tokens.py +++ b/src/weaver_kernel/tokens.py @@ -7,41 +7,16 @@ import hmac import json import logging -import os -import secrets -import threading import uuid from dataclasses import dataclass, field from typing import Any, Protocol +from ._secrets import _get_secret from .errors import TokenExpired, TokenInvalid, TokenRevoked, TokenScopeError +from .stores import InMemoryRevocationStore, RevocationStoreProtocol logger = logging.getLogger(__name__) -_DEV_SECRET: str | None = None -_DEV_SECRET_LOCK = threading.Lock() - - -def _get_secret() -> str: - """Return the HMAC secret from the environment or generate a dev fallback. - - Thread-safe: a :data:`threading.Lock` ensures only one thread generates - the fallback secret. - """ - global _DEV_SECRET - secret = os.environ.get("WEAVER_KERNEL_SECRET") - if secret: - return secret - with _DEV_SECRET_LOCK: - if _DEV_SECRET is None: - _DEV_SECRET = secrets.token_hex(32) - logger.warning( - "WEAVER_KERNEL_SECRET is not set. " - "Using a random development secret — tokens will not survive restarts. " - "Set WEAVER_KERNEL_SECRET in production." - ) - return _DEV_SECRET - # ── Token dataclass ─────────────────────────────────────────────────────────── @@ -190,12 +165,16 @@ class HMACTokenProvider: generated and a warning is logged. """ - def __init__(self, secret: str | None = None) -> None: + def __init__( + self, + secret: str | None = None, + *, + revocation_store: RevocationStoreProtocol | None = None, + ) -> None: self._secret = secret # None → use env / dev fallback at call time - self._revoked: set[str] = set() - # TODO: consider TTL-based cleanup to bound growth over long-lived instances - self._principal_tokens: dict[str, set[str]] = {} - self._revocation_lock = threading.Lock() + # Revocation state lives behind a protocol so it can be made durable + # (e.g. SQLiteRevocationStore) without weakening verify-before-invoke. + self._revocation: RevocationStoreProtocol = revocation_store or InMemoryRevocationStore() @staticmethod def _log_verify_failure(token_id: str, reason: str, **extra: Any) -> None: @@ -243,8 +222,7 @@ def issue( audit_id=audit_id, ) token.signature = self._sign(token._signable_payload()) - with self._revocation_lock: - self._principal_tokens.setdefault(principal_id, set()).add(token.token_id) + self._revocation.track(principal_id, token.token_id) logger.debug( "token_issued", extra={ @@ -265,8 +243,7 @@ def revoke(self, token_id: str) -> None: Args: token_id: The ID of the token to revoke. """ - with self._revocation_lock: - self._revoked.add(token_id) + self._revocation.revoke(token_id) def revoke_all(self, principal_id: str) -> int: """Revoke all tokens issued to a principal. @@ -278,13 +255,7 @@ def revoke_all(self, principal_id: str) -> int: The number of tokens newly revoked by this call (excluding tokens that were already revoked). """ - with self._revocation_lock: - token_ids = self._principal_tokens.get(principal_id, set()) - newly_revoked = token_ids - self._revoked - self._revoked |= newly_revoked - # Drop the index entry; new tokens for this principal will start fresh - self._principal_tokens.pop(principal_id, None) - return len(newly_revoked) + return self._revocation.revoke_principal(principal_id) def verify( self, @@ -306,10 +277,8 @@ def verify( TokenInvalid: If the HMAC signature does not verify. TokenScopeError: If principal or capability do not match. """ - # 0. Revocation (fast set lookup before any crypto) - with self._revocation_lock: - is_revoked = token.token_id in self._revoked - if is_revoked: + # 0. Revocation (fast lookup before any crypto) + if self._revocation.is_revoked(token.token_id): self._log_verify_failure(token.token_id, "revoked") raise TokenRevoked(f"Token '{token.token_id}' has been revoked.") diff --git a/tests/test_audit_chain.py b/tests/test_audit_chain.py new file mode 100644 index 0000000..25bcb30 --- /dev/null +++ b/tests/test_audit_chain.py @@ -0,0 +1,106 @@ +"""Tests for the hash-chained audit-log envelope (issue #127).""" + +from __future__ import annotations + +import dataclasses + +from weaver_kernel.stores.audit_chain import ( + GENESIS_HASH, + TraceRecord, + build_record, + compute_record_hash, + verify_chain, +) + +SECRET = "chain-test-secret" + + +def _chain(n: int, *, secret: str = SECRET) -> list[TraceRecord]: + records: list[TraceRecord] = [] + prev = GENESIS_HASH + for i in range(n): + rec = build_record(i, prev, {"action_id": f"a{i}", "n": i}, secret=secret) + records.append(rec) + prev = rec.record_hash + return records + + +def test_empty_chain_verifies() -> None: + result = verify_chain([], secret=SECRET) + assert result.ok + assert result.records_checked == 0 + assert result.first_bad_seq is None + + +def test_valid_chain_verifies() -> None: + result = verify_chain(_chain(5), secret=SECRET) + assert result.ok + assert result.records_checked == 5 + + +def test_first_record_links_to_genesis() -> None: + records = _chain(1) + assert records[0].prev_hash == GENESIS_HASH + + +def test_hash_is_deterministic() -> None: + payload = {"action_id": "a", "x": 1} + assert compute_record_hash(0, GENESIS_HASH, payload, secret=SECRET) == compute_record_hash( + 0, GENESIS_HASH, payload, secret=SECRET + ) + + +def test_mutation_detected() -> None: + records = _chain(4) + records[2].trace["n"] = 999 # tamper with content, leave stored hash intact + result = verify_chain(records, secret=SECRET) + assert not result.ok + assert result.first_bad_seq == 2 + assert "Tampered" in result.detail + + +def test_deletion_detected() -> None: + records = _chain(4) + del records[2] # seq jumps 1,? -> 3 after re-walk; link/seq breaks + result = verify_chain(records, secret=SECRET) + assert not result.ok + assert result.first_bad_seq == 3 + + +def test_insertion_detected() -> None: + records = _chain(3) + forged = build_record(1, records[0].record_hash, {"action_id": "x"}, secret=SECRET) + records.insert(1, forged) # duplicate seq=1 with a different record + result = verify_chain(records, secret=SECRET) + assert not result.ok + + +def test_reorder_detected() -> None: + records = _chain(4) + records[1], records[2] = records[2], records[1] + result = verify_chain(records, secret=SECRET) + assert not result.ok + assert result.first_bad_seq == 2 # seq 2 appears where seq 1 was expected + + +def test_wrong_secret_fails() -> None: + records = _chain(3, secret=SECRET) + result = verify_chain(records, secret="different-secret") + assert not result.ok + assert result.first_bad_seq == 0 + + +def test_checkpoint_genesis_allows_suffix_verification() -> None: + records = _chain(5) + suffix = records[2:] # drop the first two as if pruned + # With the default genesis the suffix breaks; with the checkpoint hash of the + # last pruned record it verifies. + assert not verify_chain(suffix, secret=SECRET).ok + ok = verify_chain(suffix, secret=SECRET, genesis_prev_hash=records[1].record_hash) + assert ok.ok + assert ok.records_checked == 3 + + +def test_record_is_a_slots_dataclass() -> None: + rec = _chain(1)[0] + assert dataclasses.is_dataclass(rec) diff --git a/tests/test_cli_audit.py b/tests/test_cli_audit.py new file mode 100644 index 0000000..046dbbd --- /dev/null +++ b/tests/test_cli_audit.py @@ -0,0 +1,112 @@ +"""Tests for ``weaver-kernel audit`` (issue #147).""" + +from __future__ import annotations + +import datetime +import json +import sqlite3 +from pathlib import Path + +import pytest + +from weaver_kernel.cli import main +from weaver_kernel.models import ActionTrace +from weaver_kernel.stores import SQLiteTraceStore + +SECRET = "cli-audit-test-secret" +BASE = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + + +def _trace( + action_id: str, *, principal: str, error: str | None = None, day: int = 0 +) -> ActionTrace: + return ActionTrace( + action_id=action_id, + capability_id="billing.list", + principal_id=principal, + token_id="tok", + invoked_at=BASE + datetime.timedelta(days=day), + args={}, + response_mode="summary", + driver_id="memory", + error=error, + ) + + +@pytest.fixture() +def store_path(tmp_path: Path) -> str: + db = tmp_path / "audit.db" + store = SQLiteTraceStore(db, secret=SECRET) + store.record(_trace("act-a", principal="u1", day=0)) + store.record(_trace("act-b", principal="u2", day=1)) + store.record(_trace("act-c", principal="u1", error="boom", day=2)) + store.close() + return str(db) + + +def test_list_shows_all(store_path: str, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["audit", "list", "--store", store_path, "--secret", SECRET]) + out = capsys.readouterr().out + assert rc == 0 + assert "act-a" in out and "act-b" in out and "act-c" in out + + +def test_list_json(store_path: str, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["audit", "list", "--store", store_path, "--secret", SECRET, "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert {row["action_id"] for row in data} == {"act-a", "act-b", "act-c"} + + +def test_list_filter_by_principal(store_path: str, capsys: pytest.CaptureFixture[str]) -> None: + main(["audit", "list", "--store", store_path, "--secret", SECRET, "--principal", "u1"]) + out = capsys.readouterr().out + assert "act-a" in out and "act-c" in out and "act-b" not in out + + +def test_list_filter_by_status_failed(store_path: str, capsys: pytest.CaptureFixture[str]) -> None: + main(["audit", "list", "--store", store_path, "--secret", SECRET, "--status", "failed"]) + out = capsys.readouterr().out + assert "act-c" in out and "act-a" not in out + + +def test_show(store_path: str, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["audit", "show", "act-b", "--store", store_path, "--secret", SECRET]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["action_id"] == "act-b" + assert payload["principal_id"] == "u2" + + +def test_show_unknown_exits_nonzero(store_path: str, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["audit", "show", "nope", "--store", store_path, "--secret", SECRET]) + assert rc == 1 + assert "nope" in capsys.readouterr().err + + +def test_verify_ok(store_path: str, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["audit", "verify", "--store", store_path, "--secret", SECRET]) + assert rc == 0 + assert "OK" in capsys.readouterr().out + + +def test_verify_detects_tamper(store_path: str, capsys: pytest.CaptureFixture[str]) -> None: + conn = sqlite3.connect(store_path) + conn.execute( + "UPDATE traces SET payload = ? WHERE action_id = ?", + ('{"action_id":"act-a","tampered":true}', "act-a"), + ) + conn.commit() + conn.close() + rc = main(["audit", "verify", "--store", store_path, "--secret", SECRET]) + assert rc == 1 + assert "TAMPER" in capsys.readouterr().out + + +def test_export_to_file(store_path: str, tmp_path: Path) -> None: + out = tmp_path / "export.jsonl" + rc = main(["audit", "export", "--store", store_path, "--secret", SECRET, "--out", str(out)]) + assert rc == 0 + lines = [ln for ln in out.read_text(encoding="utf-8").splitlines() if ln.strip()] + assert len(lines) == 3 + assert json.loads(lines[0])["action_id"] == "act-a" diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py new file mode 100644 index 0000000..c112337 --- /dev/null +++ b/tests/test_cli_doctor.py @@ -0,0 +1,42 @@ +"""Tests for ``weaver-kernel doctor`` (issue #124).""" + +from __future__ import annotations + +import json + +import pytest + +from weaver_kernel.cli import main + + +def test_doctor_passes_with_secret_set( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("WEAVER_KERNEL_SECRET", "doctor-test-secret") + rc = main(["doctor"]) + out = capsys.readouterr().out + assert rc == 0 + assert "token_vector" in out + assert "audit_chain" in out + assert "WEAVER_KERNEL_SECRET is set" in out + + +def test_doctor_warns_without_secret( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.delenv("WEAVER_KERNEL_SECRET", raising=False) + rc = main(["doctor"]) + out = capsys.readouterr().out + # Missing secret is a warning, not an error: doctor still exits 0. + assert rc == 0 + assert "not set" in out + + +def test_doctor_json(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + monkeypatch.setenv("WEAVER_KERNEL_SECRET", "doctor-test-secret") + rc = main(["doctor", "--json"]) + assert rc == 0 + checks = json.loads(capsys.readouterr().out) + names = {c["name"] for c in checks} + assert {"python", "secret", "token_vector", "audit_chain"} <= names + assert all(c["status"] in {"ok", "warn", "error"} for c in checks) diff --git a/tests/test_handles.py b/tests/test_handles.py index a491e58..7526e9a 100644 --- a/tests/test_handles.py +++ b/tests/test_handles.py @@ -318,3 +318,21 @@ def test_expand_no_constraints_is_unchanged(store: HandleStore) -> None: handle = store.store("cap.x", _granted_rows()) frame = store.expand(handle, query={"limit": 2}) assert len(frame.table_preview) == 2 + + +# ── Negative pagination (grant-cap bypass regression) ──────────────────────── + + +def test_negative_limit_rejected(store: HandleStore) -> None: + """A negative limit must not slice past a grant cap (e.g. max_rows=0).""" + handle = store.store("cap.x", [{"id": 0}, {"id": 1}], constraints={"max_rows": 0}) + with pytest.raises(HandleConstraintViolation) as exc: + store.expand(handle, query={"limit": -1}) + assert exc.value.reason_code == DenialReason.INVALID_CONSTRAINT + + +def test_negative_offset_rejected(store: HandleStore) -> None: + handle = store.store("cap.x", [{"id": 0}, {"id": 1}]) + with pytest.raises(HandleConstraintViolation) as exc: + store.expand(handle, query={"offset": -1}) + assert exc.value.reason_code == DenialReason.INVALID_CONSTRAINT diff --git a/tests/test_stores_jsonl.py b/tests/test_stores_jsonl.py new file mode 100644 index 0000000..b8b9060 --- /dev/null +++ b/tests/test_stores_jsonl.py @@ -0,0 +1,90 @@ +"""Tests for the append-only JSONL trace store (issues #126, #127).""" + +from __future__ import annotations + +import datetime +from pathlib import Path + +import pytest + +from weaver_kernel.errors import AgentKernelError +from weaver_kernel.models import ActionTrace +from weaver_kernel.stores import JsonlTraceStore + +SECRET = "jsonl-store-test-secret" + + +def _trace(action_id: str) -> ActionTrace: + return ActionTrace( + action_id=action_id, + capability_id="billing.list", + principal_id="u1", + token_id="tok-1", + invoked_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), + args={"a": 1}, + response_mode="summary", + driver_id="memory", + ) + + +def test_record_get_and_list(tmp_path: Path) -> None: + store = JsonlTraceStore(tmp_path / "a.jsonl", secret=SECRET) + store.record(_trace("act-0")) + store.record(_trace("act-1")) + assert store.get("act-1").action_id == "act-1" + assert [t.action_id for t in store.list_all()] == ["act-0", "act-1"] + + +def test_get_unknown_raises(tmp_path: Path) -> None: + store = JsonlTraceStore(tmp_path / "a.jsonl", secret=SECRET) + with pytest.raises(AgentKernelError, match="act-missing"): + store.get("act-missing") + + +def test_one_line_per_record(tmp_path: Path) -> None: + path = tmp_path / "a.jsonl" + store = JsonlTraceStore(path, secret=SECRET) + store.record(_trace("act-0")) + store.record(_trace("act-1")) + lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()] + assert len(lines) == 2 + + +def test_chain_verifies(tmp_path: Path) -> None: + store = JsonlTraceStore(tmp_path / "a.jsonl", secret=SECRET) + for i in range(3): + store.record(_trace(f"act-{i}")) + assert store.verify_chain().ok + + +def test_resume_chain_after_reopen(tmp_path: Path) -> None: + path = tmp_path / "a.jsonl" + first = JsonlTraceStore(path, secret=SECRET) + first.record(_trace("act-0")) + reopened = JsonlTraceStore(path, secret=SECRET) + reopened.record(_trace("act-1")) + assert [r.seq for r in reopened.list_records()] == [0, 1] + assert reopened.verify_chain().ok + + +def test_tampered_line_detected(tmp_path: Path) -> None: + path = tmp_path / "a.jsonl" + store = JsonlTraceStore(path, secret=SECRET) + store.record(_trace("act-0")) + store.record(_trace("act-1")) + lines = path.read_text(encoding="utf-8").splitlines() + assert '"a": 1' in lines[0] + lines[0] = lines[0].replace('"a": 1', '"a": 999') + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + result = JsonlTraceStore(path, secret=SECRET).verify_chain() + assert not result.ok + assert result.first_bad_seq == 0 + + +def test_blank_lines_ignored(tmp_path: Path) -> None: + path = tmp_path / "a.jsonl" + store = JsonlTraceStore(path, secret=SECRET) + store.record(_trace("act-0")) + with path.open("a", encoding="utf-8") as handle: + handle.write("\n") + assert len(JsonlTraceStore(path, secret=SECRET).list_all()) == 1 diff --git a/tests/test_stores_sqlite.py b/tests/test_stores_sqlite.py new file mode 100644 index 0000000..490350c --- /dev/null +++ b/tests/test_stores_sqlite.py @@ -0,0 +1,170 @@ +"""Tests for the SQLite durable stores (issues #126, #127).""" + +from __future__ import annotations + +import datetime +import sqlite3 +from pathlib import Path + +import pytest + +from weaver_kernel import HMACTokenProvider +from weaver_kernel.errors import AgentKernelError, TokenRevoked +from weaver_kernel.models import ActionTrace +from weaver_kernel.stores import SQLiteRevocationStore, SQLiteTraceStore + +SECRET = "sqlite-store-test-secret" + + +def _trace(action_id: str, *, when: datetime.datetime | None = None) -> ActionTrace: + return ActionTrace( + action_id=action_id, + capability_id="billing.list", + principal_id="u1", + token_id="tok-1", + invoked_at=when or datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), + args={"a": 1}, + response_mode="summary", + driver_id="memory", + ) + + +# ── SQLiteTraceStore ───────────────────────────────────────────────────────── + + +def test_record_get_and_list(tmp_path: Path) -> None: + store = SQLiteTraceStore(tmp_path / "a.db", secret=SECRET) + store.record(_trace("act-0")) + store.record(_trace("act-1")) + assert store.get("act-0").action_id == "act-0" + assert [t.action_id for t in store.list_all()] == ["act-0", "act-1"] + + +def test_get_unknown_raises(tmp_path: Path) -> None: + store = SQLiteTraceStore(tmp_path / "a.db", secret=SECRET) + with pytest.raises(AgentKernelError, match="act-missing"): + store.get("act-missing") + + +def test_round_trip_preserves_fields(tmp_path: Path) -> None: + store = SQLiteTraceStore(tmp_path / "a.db", secret=SECRET) + store.record(_trace("act-rt")) + got = store.get("act-rt") + assert got.args == {"a": 1} + assert got.invoked_at == datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + assert got.driver_id == "memory" + + +def test_persistence_survives_reopen(tmp_path: Path) -> None: + db = tmp_path / "a.db" + first = SQLiteTraceStore(db, secret=SECRET) + first.record(_trace("act-0")) + first.close() + reopened = SQLiteTraceStore(db, secret=SECRET) + assert [t.action_id for t in reopened.list_all()] == ["act-0"] + assert reopened.verify_chain().ok + + +def test_chain_verifies_and_continues_across_reopen(tmp_path: Path) -> None: + db = tmp_path / "a.db" + first = SQLiteTraceStore(db, secret=SECRET) + first.record(_trace("act-0")) + first.close() + reopened = SQLiteTraceStore(db, secret=SECRET) + reopened.record(_trace("act-1")) + records = reopened.list_records() + assert [r.seq for r in records] == [0, 1] + assert reopened.verify_chain().ok + + +def test_tamper_with_db_row_detected(tmp_path: Path) -> None: + db = tmp_path / "a.db" + store = SQLiteTraceStore(db, secret=SECRET) + store.record(_trace("act-0")) + store.record(_trace("act-1")) + store.close() + # Mutate a stored payload directly, leaving record_hash untouched. + conn = sqlite3.connect(str(db)) + conn.execute( + "UPDATE traces SET payload = ? WHERE action_id = ?", + ('{"action_id":"act-0","tampered":true}', "act-0"), + ) + conn.commit() + conn.close() + result = SQLiteTraceStore(db, secret=SECRET).verify_chain() + assert not result.ok + assert result.first_bad_seq == 0 + + +def test_wrong_secret_fails_verification(tmp_path: Path) -> None: + db = tmp_path / "a.db" + SQLiteTraceStore(db, secret=SECRET).record(_trace("act-0")) + assert not SQLiteTraceStore(db, secret="other-secret").verify_chain().ok + + +def test_prune_preserves_suffix_verifiability(tmp_path: Path) -> None: + store = SQLiteTraceStore(tmp_path / "a.db", secret=SECRET) + base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + for i in range(5): + store.record(_trace(f"act-{i}", when=base + datetime.timedelta(days=i))) + pruned = store.prune(before=base + datetime.timedelta(days=2)) + assert pruned == 2 + assert [t.action_id for t in store.list_all()] == ["act-2", "act-3", "act-4"] + assert store.verify_chain().ok + + +def test_prune_then_append_keeps_chain_verifiable(tmp_path: Path) -> None: + store = SQLiteTraceStore(tmp_path / "a.db", secret=SECRET) + base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + for i in range(3): + store.record(_trace(f"act-{i}", when=base + datetime.timedelta(days=i))) + store.prune(before=base + datetime.timedelta(days=1)) + store.record(_trace("act-new", when=base + datetime.timedelta(days=9))) + assert store.verify_chain().ok + assert [r.seq for r in store.list_records()] == [1, 2, 3] + + +def test_prune_nothing_returns_zero(tmp_path: Path) -> None: + store = SQLiteTraceStore(tmp_path / "a.db", secret=SECRET) + store.record(_trace("act-0")) + assert store.prune(before=datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc)) == 0 + + +# ── SQLiteRevocationStore ────────────────────────────────────────────────────── + + +def test_revocation_basic(tmp_path: Path) -> None: + store = SQLiteRevocationStore(tmp_path / "r.db") + assert not store.is_revoked("t1") + store.revoke("t1") + assert store.is_revoked("t1") + store.revoke("t1") # idempotent + + +def test_revoke_principal_counts_only_newly_revoked(tmp_path: Path) -> None: + store = SQLiteRevocationStore(tmp_path / "r.db") + store.track("p1", "t1") + store.track("p1", "t2") + store.revoke("t1") + assert store.revoke_principal("p1") == 1 # only t2 newly revoked + assert store.is_revoked("t2") + + +def test_revocation_survives_reopen(tmp_path: Path) -> None: + db = tmp_path / "r.db" + first = SQLiteRevocationStore(db) + first.revoke("t1") + first.close() + assert SQLiteRevocationStore(db).is_revoked("t1") + + +def test_revoked_token_stays_revoked_for_fresh_provider(tmp_path: Path) -> None: + """Issue #126 acceptance: revocation outlives the provider instance.""" + db = tmp_path / "r.db" + provider = HMACTokenProvider(secret=SECRET, revocation_store=SQLiteRevocationStore(db)) + token = provider.issue("cap.x", "u1") + provider.revoke(token.token_id) + + fresh = HMACTokenProvider(secret=SECRET, revocation_store=SQLiteRevocationStore(db)) + with pytest.raises(TokenRevoked, match="revoked"): + fresh.verify(token, expected_principal_id="u1", expected_capability_id="cap.x") diff --git a/tests/test_tokens.py b/tests/test_tokens.py index ac47a68..304973e 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -95,19 +95,21 @@ def test_dev_secret_warning(caplog: pytest.LogCaptureFixture) -> None: """A provider with no secret should generate a warning.""" import logging - import weaver_kernel.tokens as tok_mod + import weaver_kernel._secrets as sec_mod - # Save and restore _DEV_SECRET to avoid leaking state to other tests - original = tok_mod._DEV_SECRET + # Save and restore _DEV_SECRET to avoid leaking state to other tests. The + # secret-loading path now lives in weaver_kernel._secrets (shared by token + # signing and audit-chain hashing). + original = sec_mod._DEV_SECRET try: - tok_mod._DEV_SECRET = None + sec_mod._DEV_SECRET = None provider_no_secret = HMACTokenProvider(secret=None) - with caplog.at_level(logging.WARNING, logger="weaver_kernel.tokens"): + with caplog.at_level(logging.WARNING, logger="weaver_kernel._secrets"): token = provider_no_secret.issue("cap.x", "user-1") assert "WEAVER_KERNEL_SECRET" in caplog.text assert token.signature != "" finally: - tok_mod._DEV_SECRET = original + sec_mod._DEV_SECRET = original # ── Revocation ───────────────────────────────────────────────────────────────── From 5f004afdd34a8fd98e63be686e0b0d1b9e8e2fff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 20:50:07 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20typed=20errors=20on=20corrupt=20store=20data,=20pru?= =?UTF-8?q?ne=20tz,=20missing-store=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve all 7 review comments on PR #148: - Remap stdlib parsing/JSON errors to AgentKernelError so tampered or corrupt store data surfaces as a typed, CLI-renderable error instead of a bare ValueError/JSONDecodeError traceback: - stores/_trace_codec.decode_trace: catch ValueError/TypeError (bad invoked_at / sensitivity) in addition to KeyError. - stores/sqlite: shared _loads_payload() helper used by get(), list_all(), and list_records(); also rejects non-object JSON. get() names the action_id. - stores/jsonl._iter_records: remap with file:line context. - stores/sqlite.prune(): normalise `before` to UTC (naive assumed UTC) so the lexicographic comparison against stored UTC ISO-8601 timestamps is correct. - cli/_audit.open_trace_store(): error if the --store path does not exist (except ":memory:") so `audit verify` can't falsely report OK on a freshly created empty store from a mistyped path. Adds regression tests (test_trace_codec.py + corruption/prune/missing-store cases). make ci: 647 passed, 1 skipped; lint, mypy --strict, examples green. https://claude.ai/code/session_014vcsXSCqnFprxCkMiBQGtY --- src/weaver_kernel/cli/_audit.py | 14 ++++++- src/weaver_kernel/stores/_trace_codec.py | 8 +++- src/weaver_kernel/stores/jsonl.py | 18 ++++++--- src/weaver_kernel/stores/sqlite.py | 39 ++++++++++++++---- tests/test_cli_audit.py | 7 ++++ tests/test_stores_jsonl.py | 12 ++++++ tests/test_stores_sqlite.py | 32 +++++++++++++++ tests/test_trace_codec.py | 51 ++++++++++++++++++++++++ 8 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 tests/test_trace_codec.py diff --git a/src/weaver_kernel/cli/_audit.py b/src/weaver_kernel/cli/_audit.py index a79baeb..14048a3 100644 --- a/src/weaver_kernel/cli/_audit.py +++ b/src/weaver_kernel/cli/_audit.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Protocol +from ..errors import AgentKernelError from ..models import ActionTrace from ..stores import TraceStoreProtocol from ..stores._trace_codec import encode_trace @@ -39,7 +40,18 @@ def verify_chain(self) -> ChainVerificationResult: ... def open_trace_store( path: str, fmt: str | None, *, secret: str | None = None ) -> _VerifiableTraceStore: - """Open a persisted trace store, inferring the format from the path suffix.""" + """Open a persisted trace store, inferring the format from the path suffix. + + Raises: + AgentKernelError: If *path* does not exist. Opening would otherwise + create an empty store and make ``audit verify`` falsely report OK on + a mistyped path, hiding the misconfiguration. + """ + if path != ":memory:" and not Path(path).exists(): + raise AgentKernelError( + f"Trace store '{path}' does not exist. Check the --store path " + "(the audit commands read an existing store; they do not create one)." + ) resolved = fmt or ("jsonl" if path.endswith(".jsonl") else "sqlite") if resolved == "jsonl": return JsonlTraceStore(path, secret=secret) diff --git a/src/weaver_kernel/stores/_trace_codec.py b/src/weaver_kernel/stores/_trace_codec.py index 526da5d..20d975b 100644 --- a/src/weaver_kernel/stores/_trace_codec.py +++ b/src/weaver_kernel/stores/_trace_codec.py @@ -26,8 +26,10 @@ def decode_trace(payload: dict[str, Any]) -> ActionTrace: """Reconstruct an :class:`ActionTrace` from a persisted payload. Raises: - AgentKernelError: If the payload is missing a required field — surfaced - as a kernel error rather than a bare ``KeyError`` (see AGENTS.md). + AgentKernelError: If the payload is missing a required field, or carries + a malformed ``invoked_at`` / ``sensitivity`` — surfaced as a kernel + error rather than a bare ``KeyError``/``ValueError`` (see AGENTS.md), + so tampered data cannot crash the CLI with a traceback. """ try: return ActionTrace( @@ -48,3 +50,5 @@ def decode_trace(payload: dict[str, Any]) -> ActionTrace: raise AgentKernelError( f"Persisted trace payload is missing required field {exc}." ) from exc + except (ValueError, TypeError) as exc: + raise AgentKernelError(f"Persisted trace payload is malformed: {exc}.") from exc diff --git a/src/weaver_kernel/stores/jsonl.py b/src/weaver_kernel/stores/jsonl.py index 060d7c6..1112b46 100644 --- a/src/weaver_kernel/stores/jsonl.py +++ b/src/weaver_kernel/stores/jsonl.py @@ -45,19 +45,25 @@ def _iter_records(self) -> list[TraceRecord]: return [] records: list[TraceRecord] = [] with self._path.open(encoding="utf-8") as handle: - for line in handle: - line = line.strip() + for lineno, raw in enumerate(handle, start=1): + line = raw.strip() if not line: continue - obj = json.loads(line) - records.append( - TraceRecord( + try: + obj = json.loads(line) + record = TraceRecord( seq=int(obj["seq"]), prev_hash=str(obj["prev_hash"]), record_hash=str(obj["record_hash"]), trace=obj["trace"], ) - ) + except (json.JSONDecodeError, KeyError, ValueError, TypeError) as exc: + # A malformed/tampered line must surface as a typed error the + # CLI can render, not a bare ValueError traceback (AGENTS.md). + raise AgentKernelError( + f"Corrupted trace record at {self._path}:{lineno}: {exc}." + ) from exc + records.append(record) return records # ── TraceStoreProtocol ─────────────────────────────────────────────────── diff --git a/src/weaver_kernel/stores/sqlite.py b/src/weaver_kernel/stores/sqlite.py index 1148fe2..ff1cc58 100644 --- a/src/weaver_kernel/stores/sqlite.py +++ b/src/weaver_kernel/stores/sqlite.py @@ -17,6 +17,7 @@ import sqlite3 import threading from pathlib import Path +from typing import Any from .._secrets import resolve_hmac_secret from ..errors import AgentKernelError @@ -31,6 +32,22 @@ ) +def _loads_payload(raw: str, *, context: str) -> dict[str, Any]: + """Parse a stored JSON payload, remapping corruption to a typed error. + + A tampered or hand-edited row must surface as :class:`AgentKernelError` + (which the CLI renders cleanly), never as a bare ``JSONDecodeError`` / + ``ValueError`` escaping to callers (see AGENTS.md). + """ + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise AgentKernelError(f"Corrupted trace payload {context}: {exc}.") from exc + if not isinstance(data, dict): + raise AgentKernelError(f"Corrupted trace payload {context}: expected a JSON object.") + return data + + class SQLiteTraceStore: """Durable, hash-chained :class:`TraceStoreProtocol` backend. @@ -41,6 +58,7 @@ class SQLiteTraceStore: def __init__(self, path: str | Path, *, secret: str | None = None) -> None: self._secret = resolve_hmac_secret(secret) + self._path = str(path) self._lock = threading.Lock() self._conn = sqlite3.connect(str(path), check_same_thread=False) self._conn.execute( @@ -105,12 +123,15 @@ def get(self, action_id: str) -> ActionTrace: ).fetchone() if row is None: raise AgentKernelError(f"No action trace found for action_id='{action_id}'.") - return decode_trace(json.loads(row[0])) + return decode_trace(_loads_payload(row[0], context=f"for action_id='{action_id}'")) def list_all(self) -> list[ActionTrace]: """Return all traces in chain order.""" - rows = self._conn.execute("SELECT payload FROM traces ORDER BY seq").fetchall() - return [decode_trace(json.loads(r[0])) for r in rows] + rows = self._conn.execute("SELECT seq, payload FROM traces ORDER BY seq").fetchall() + return [ + decode_trace(_loads_payload(r[1], context=f"at seq {int(r[0])} in {self._path}")) + for r in rows + ] # ── Audit chain (issue #127) ────────────────────────────────────────────── @@ -124,7 +145,7 @@ def list_records(self) -> list[TraceRecord]: seq=int(r[0]), prev_hash=str(r[1]), record_hash=str(r[2]), - trace=json.loads(r[3]), + trace=_loads_payload(r[3], context=f"at seq {int(r[0])} in {self._path}"), ) for r in rows ] @@ -141,13 +162,17 @@ def prune(self, before: datetime.datetime) -> int: checkpoint, so :meth:`verify_chain` still validates the retained suffix. Args: - before: Timezone-aware cutoff; records with ``invoked_at`` strictly - before this are removed. + before: Cutoff; records with ``invoked_at`` strictly before this are + removed. Normalised to UTC before comparison (a naive datetime is + assumed to be UTC) so the lexicographic comparison against the + stored UTC ISO-8601 timestamps is correct. Returns: The number of records pruned. """ - cutoff = before.isoformat() + if before.tzinfo is None: + before = before.replace(tzinfo=datetime.timezone.utc) + cutoff = before.astimezone(datetime.timezone.utc).isoformat() with self._lock: doomed = self._conn.execute( "SELECT seq, record_hash FROM traces WHERE invoked_at < ? ORDER BY seq", diff --git a/tests/test_cli_audit.py b/tests/test_cli_audit.py index 046dbbd..b2061c9 100644 --- a/tests/test_cli_audit.py +++ b/tests/test_cli_audit.py @@ -110,3 +110,10 @@ def test_export_to_file(store_path: str, tmp_path: Path) -> None: lines = [ln for ln in out.read_text(encoding="utf-8").splitlines() if ln.strip()] assert len(lines) == 3 assert json.loads(lines[0])["action_id"] == "act-a" + + +def test_missing_store_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + missing = str(tmp_path / "nope.db") + rc = main(["audit", "verify", "--store", missing, "--secret", SECRET]) + assert rc == 1 + assert "does not exist" in capsys.readouterr().err diff --git a/tests/test_stores_jsonl.py b/tests/test_stores_jsonl.py index b8b9060..e8406f5 100644 --- a/tests/test_stores_jsonl.py +++ b/tests/test_stores_jsonl.py @@ -88,3 +88,15 @@ def test_blank_lines_ignored(tmp_path: Path) -> None: with path.open("a", encoding="utf-8") as handle: handle.write("\n") assert len(JsonlTraceStore(path, secret=SECRET).list_all()) == 1 + + +def test_corrupted_line_raises_typed_error(tmp_path: Path) -> None: + path = tmp_path / "a.jsonl" + store = JsonlTraceStore(path, secret=SECRET) + store.record(_trace("act-0")) + with path.open("a", encoding="utf-8") as handle: + handle.write("{ not valid json\n") + # The store reads the file on construction, so a corrupt line surfaces as a + # typed error there as well as on any later read. + with pytest.raises(AgentKernelError, match="Corrupted trace record"): + JsonlTraceStore(path, secret=SECRET) diff --git a/tests/test_stores_sqlite.py b/tests/test_stores_sqlite.py index 490350c..fdf892d 100644 --- a/tests/test_stores_sqlite.py +++ b/tests/test_stores_sqlite.py @@ -168,3 +168,35 @@ def test_revoked_token_stays_revoked_for_fresh_provider(tmp_path: Path) -> None: fresh = HMACTokenProvider(secret=SECRET, revocation_store=SQLiteRevocationStore(db)) with pytest.raises(TokenRevoked, match="revoked"): fresh.verify(token, expected_principal_id="u1", expected_capability_id="cap.x") + + +# ── Corruption surfaces as a typed error (not a bare ValueError) ───────────── + + +def test_corrupted_payload_raises_typed_error(tmp_path: Path) -> None: + db = tmp_path / "a.db" + store = SQLiteTraceStore(db, secret=SECRET) + store.record(_trace("act-0")) + store.close() + conn = sqlite3.connect(str(db)) + conn.execute("UPDATE traces SET payload = 'not json' WHERE seq = 0") + conn.commit() + conn.close() + reopened = SQLiteTraceStore(db, secret=SECRET) + with pytest.raises(AgentKernelError, match="Corrupted trace payload"): + reopened.list_all() + with pytest.raises(AgentKernelError, match="Corrupted trace payload"): + reopened.get("act-0") + with pytest.raises(AgentKernelError, match="Corrupted trace payload"): + reopened.verify_chain() + + +def test_prune_accepts_naive_datetime_as_utc(tmp_path: Path) -> None: + store = SQLiteTraceStore(tmp_path / "a.db", secret=SECRET) + base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + for i in range(3): + store.record(_trace(f"act-{i}", when=base + datetime.timedelta(days=i))) + # A naive cutoff is treated as UTC; days 0 and 1 are pruned. + pruned = store.prune(before=datetime.datetime(2026, 1, 2, 12)) + assert pruned == 2 + assert store.verify_chain().ok diff --git a/tests/test_trace_codec.py b/tests/test_trace_codec.py new file mode 100644 index 0000000..04cbeba --- /dev/null +++ b/tests/test_trace_codec.py @@ -0,0 +1,51 @@ +"""Tests for the persisted-trace encode/decode codec.""" + +from __future__ import annotations + +import datetime + +import pytest + +from weaver_kernel.errors import AgentKernelError +from weaver_kernel.models import ActionTrace +from weaver_kernel.stores._trace_codec import decode_trace, encode_trace + + +def _trace() -> ActionTrace: + return ActionTrace( + action_id="a", + capability_id="cap.x", + principal_id="u1", + token_id="t", + invoked_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), + args={"k": "v"}, + response_mode="summary", + driver_id="memory", + ) + + +def test_round_trip() -> None: + decoded = decode_trace(encode_trace(_trace())) + assert decoded.action_id == "a" + assert decoded.invoked_at == datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + + +def test_missing_field_raises_typed_error() -> None: + payload = encode_trace(_trace()) + del payload["capability_id"] + with pytest.raises(AgentKernelError, match="missing required field"): + decode_trace(payload) + + +def test_malformed_timestamp_raises_typed_error() -> None: + payload = encode_trace(_trace()) + payload["invoked_at"] = "not-a-timestamp" + with pytest.raises(AgentKernelError, match="malformed"): + decode_trace(payload) + + +def test_unknown_sensitivity_raises_typed_error() -> None: + payload = encode_trace(_trace()) + payload["sensitivity"] = "BOGUS" + with pytest.raises(AgentKernelError, match="malformed"): + decode_trace(payload) From 99b7defb9b15900f493cec79c4d3763e8a931a48 Mon Sep 17 00:00:00 2001 From: dgenio Date: Sun, 14 Jun 2026 12:25:13 +0000 Subject: [PATCH 3/3] fix: scope audit-chain integrity claims, harden SQLite trace store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address audit findings on the durable persistence / audit-log feature set: - Integrity model honesty (#127): verify_chain() detects mutation, interior insertion/deletion, and reordering, but NOT tail-truncation or whole-log deletion (no signed head anchor — a self-consistent prefix still verifies). Scope the claim accurately in docs/security.md, the audit_chain docstrings, and CHANGELOG; note an out-of-band head anchor as a follow-up. - Remap sqlite3.IntegrityError on SQLiteTraceStore.record() (duplicate action_id / seq) to AgentKernelError so the audit hot path and CLI never leak a bare traceback (AGENTS.md). - Remap sqlite3.DatabaseError on store open (e.g. a JSONL file passed without --format jsonl) to AgentKernelError. - Document the explicit single-writer constraint on the durable trace stores (durable revocation remains multi-process). - Add regression tests for duplicate action_id and non-SQLite file open. make ci green: ruff fmt+lint, mypy --strict, 649 passed / 1 skipped, examples. --- CHANGELOG.md | 15 +++-- docs/security.md | 11 +++- src/weaver_kernel/stores/audit_chain.py | 18 ++++-- src/weaver_kernel/stores/jsonl.py | 9 ++- src/weaver_kernel/stores/sqlite.py | 80 +++++++++++++++++-------- tests/test_stores_sqlite.py | 19 ++++++ 6 files changed, 116 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee78c62..7a7548f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,10 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Hash-chained, verifiable audit log (#127).** Persisted traces are wrapped in a `prev_hash`/`record_hash` chain (HMAC-SHA256, keyed by `WEAVER_KERNEL_SECRET`). `verify_chain()` (and `SQLiteTraceStore.verify_chain()` - / `JsonlTraceStore.verify_chain()`) detect mutation, insertion, deletion, and - reordering, reporting the first divergent record. `SQLiteTraceStore.prune()` - drops old records while preserving verifiability of the retained suffix via a - checkpoint. Tamper-evidence, not non-repudiation — see `docs/security.md`. + / `JsonlTraceStore.verify_chain()`) detect mutation, interior insertion, + deletion, and reordering, reporting the first divergent record. Tail-truncation + and whole-log deletion are out of scope (no signed head anchor) — see + `docs/security.md`. `SQLiteTraceStore.prune()` drops old records while + preserving verifiability of the retained suffix via a checkpoint. + Tamper-evidence, not non-repudiation — see `docs/security.md`. - **`weaver-kernel` operator CLI.** `weaver-kernel audit list|show|verify|export` inspects, filters, verifies, and exports a persisted trace store, with `--json` on every subcommand and redaction-safe output by construction (#147). @@ -41,6 +43,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unchanged. - `Kernel(trace_store=...)` is now typed against `TraceStoreProtocol`, so any conforming backend (including the durable ones) can be injected. +- `SQLiteTraceStore` now remaps `sqlite3` errors to `AgentKernelError`: a + duplicate `action_id` (or seq) on `record()` and opening a non-SQLite file + (e.g. a JSONL store without `--format jsonl`) surface as typed errors instead + of leaking a traceback through the CLI. The durable trace stores document an + explicit single-writer constraint (durable revocation remains multi-process). ## [0.10.0] - 2026-06-07 diff --git a/docs/security.md b/docs/security.md index da2b7cc..2fcead8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -90,13 +90,22 @@ record's hash (the first record links to a genesis value). `verify_chain()` recomputes every hash and checks the linkage, so it detects: - **mutation** of any persisted record (recomputed hash diverges), -- **insertion**, **deletion**, or **reordering** (broken `prev_hash` linkage or a +- **interior insertion, deletion, or reordering** (broken `prev_hash` linkage or a non-contiguous `seq`), and reports the `seq` of the first divergent record. `SQLiteTraceStore.prune()` removes old records while preserving verifiability of the retained suffix by recording the last pruned record's hash as a checkpoint. +**Truncation is the exception.** The chain stores no signed head/length anchor, so +dropping the **most recent** records (tail truncation) — or deleting the whole +store — leaves a self-consistent prefix that still verifies: there is no broken +link or sequence gap to detect, and an empty store verifies vacuously. Detecting +truncation requires anchoring the expected head out of band (a separately stored, +signed record count + head hash); that is a planned follow-up. Until then, treat +append-only durability (JSONL shipped to a write-once collector, or a SQLite file +on append-only storage) as the truncation defense. + **What this is — and is not.** This is **tamper-evidence**: anyone who does not hold `WEAVER_KERNEL_SECRET` cannot alter the log without `verify_chain()` detecting it. It is **not non-repudiation**: a host that controls the secret can diff --git a/src/weaver_kernel/stores/audit_chain.py b/src/weaver_kernel/stores/audit_chain.py index e30ab98..bd6125d 100644 --- a/src/weaver_kernel/stores/audit_chain.py +++ b/src/weaver_kernel/stores/audit_chain.py @@ -7,9 +7,12 @@ mutation, or reordering of records is detectable by :func:`verify_chain`. Integrity model (honest scope): this gives **tamper-evidence** against post-hoc -edits by anyone who does not hold the secret. It is **not** non-repudiation — a -host that controls ``WEAVER_KERNEL_SECRET`` can forge a self-consistent chain. -See ``docs/security.md``. +edits by anyone who does not hold the secret. It detects mutation, interior +insertion/deletion, and reordering, but **not tail-truncation** (dropping the +most recent records) or whole-log deletion — the chain stores no signed head +anchor, so a self-consistent prefix (including the empty chain) still verifies. +It is **not** non-repudiation — a host that controls ``WEAVER_KERNEL_SECRET`` can +forge a self-consistent chain. See ``docs/security.md``. The chaining envelope is intentionally separate from ``ActionTrace`` semantics: ``prev_hash``/``record_hash``/``seq`` live only on the persisted record, never on @@ -115,9 +118,12 @@ def verify_chain( ) -> ChainVerificationResult: """Verify the integrity of an ordered sequence of trace records. - Detects mutation (recomputed hash differs), insertion/deletion/reordering - (broken ``prev_hash`` linkage or non-contiguous ``seq``), and a wrong secret - (every hash diverges). Records must be supplied in ascending ``seq`` order. + Detects mutation (recomputed hash differs), interior insertion/deletion/ + reordering (broken ``prev_hash`` linkage or non-contiguous ``seq``), and a + wrong secret (every hash diverges). It does **not** detect tail-truncation + (dropping the most recent records) or whole-log deletion: with no signed head + anchor, a self-consistent prefix — including the empty chain — verifies (see + ``docs/security.md``). Records must be supplied in ascending ``seq`` order. Args: records: The chain to verify, ordered by ``seq``. diff --git a/src/weaver_kernel/stores/jsonl.py b/src/weaver_kernel/stores/jsonl.py index 1112b46..38bc189 100644 --- a/src/weaver_kernel/stores/jsonl.py +++ b/src/weaver_kernel/stores/jsonl.py @@ -27,7 +27,14 @@ class JsonlTraceStore: - """Durable append-only :class:`TraceStoreProtocol` backend.""" + """Durable append-only :class:`TraceStoreProtocol` backend. + + Concurrency: this store is **single-writer**. It caches the chain head + (``seq``/``record_hash``) in memory and appends under an intra-process lock, + so two processes (or two instances) writing the same file fork the chain — + which :meth:`verify_chain` then reports as a non-contiguous sequence. Use one + writer per file; rotate externally if needed. + """ def __init__(self, path: str | Path, *, secret: str | None = None) -> None: self._secret = resolve_hmac_secret(secret) diff --git a/src/weaver_kernel/stores/sqlite.py b/src/weaver_kernel/stores/sqlite.py index ff1cc58..798498d 100644 --- a/src/weaver_kernel/stores/sqlite.py +++ b/src/weaver_kernel/stores/sqlite.py @@ -54,6 +54,13 @@ class SQLiteTraceStore: The signing secret defaults to the shared ``WEAVER_KERNEL_SECRET`` path; a store opened with a different secret than it was written with will fail :meth:`verify_chain`. + + Concurrency: this store is **single-writer**. Writes are serialised within a + process by a lock, but the chain head is read-then-written without a + cross-process transaction, so two processes writing the same file can collide + on ``seq`` (surfaced as :class:`AgentKernelError`) or fork the chain. Use one + writer per store file. Durable *revocation* (:class:`SQLiteRevocationStore`) + is safe across processes; the trace chain is not. """ def __init__(self, path: str | Path, *, secret: str | None = None) -> None: @@ -61,17 +68,29 @@ def __init__(self, path: str | Path, *, secret: str | None = None) -> None: self._path = str(path) self._lock = threading.Lock() self._conn = sqlite3.connect(str(path), check_same_thread=False) - self._conn.execute( - "CREATE TABLE IF NOT EXISTS traces (" - "seq INTEGER PRIMARY KEY, " - "action_id TEXT UNIQUE NOT NULL, " - "prev_hash TEXT NOT NULL, " - "record_hash TEXT NOT NULL, " - "invoked_at TEXT NOT NULL, " - "payload TEXT NOT NULL)" - ) - self._conn.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)") - self._conn.commit() + try: + self._conn.execute( + "CREATE TABLE IF NOT EXISTS traces (" + "seq INTEGER PRIMARY KEY, " + "action_id TEXT UNIQUE NOT NULL, " + "prev_hash TEXT NOT NULL, " + "record_hash TEXT NOT NULL, " + "invoked_at TEXT NOT NULL, " + "payload TEXT NOT NULL)" + ) + self._conn.execute( + "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)" + ) + self._conn.commit() + except sqlite3.DatabaseError as exc: + # e.g. pointing --store at a JSONL file: sqlite3 opens it but the + # first statement raises "file is not a database". Surface a typed + # error the CLI renders, not a bare traceback (see AGENTS.md). + self._conn.close() + raise AgentKernelError( + f"'{self._path}' is not a valid SQLite trace store: {exc}. " + "If this is a JSONL store, pass --format jsonl." + ) from exc # ── Internal helpers ──────────────────────────────────────────────────── @@ -102,19 +121,32 @@ def record(self, trace: ActionTrace) -> None: next_seq = int(self._get_meta("checkpoint_next_seq", "0")) prev_hash = self._get_meta("checkpoint_hash", GENESIS_HASH) record = build_record(next_seq, prev_hash, payload, secret=self._secret) - self._conn.execute( - "INSERT INTO traces (seq, action_id, prev_hash, record_hash, invoked_at, payload) " - "VALUES (?, ?, ?, ?, ?, ?)", - ( - record.seq, - trace.action_id, - record.prev_hash, - record.record_hash, - payload["invoked_at"], - json.dumps(payload), - ), - ) - self._conn.commit() + try: + self._conn.execute( + "INSERT INTO traces " + "(seq, action_id, prev_hash, record_hash, invoked_at, payload) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + record.seq, + trace.action_id, + record.prev_hash, + record.record_hash, + payload["invoked_at"], + json.dumps(payload), + ), + ) + self._conn.commit() + except sqlite3.IntegrityError as exc: + # Duplicate action_id (UNIQUE) or seq (PRIMARY KEY). Each + # invocation has a fresh action_id, so this signals a re-recorded + # trace or a concurrent second writer (the store is single-writer; + # see the class docstring). Surface a typed error rather than let a + # bare sqlite3.IntegrityError escape to the caller (see AGENTS.md). + self._conn.rollback() + raise AgentKernelError( + f"Cannot record trace: action_id '{trace.action_id}' is already " + "present in the store (duplicate record or concurrent write)." + ) from exc def get(self, action_id: str) -> ActionTrace: """Return the trace for *action_id*.""" diff --git a/tests/test_stores_sqlite.py b/tests/test_stores_sqlite.py index fdf892d..72f15c7 100644 --- a/tests/test_stores_sqlite.py +++ b/tests/test_stores_sqlite.py @@ -191,6 +191,25 @@ def test_corrupted_payload_raises_typed_error(tmp_path: Path) -> None: reopened.verify_chain() +def test_duplicate_action_id_raises_typed_error(tmp_path: Path) -> None: + """Re-recording an action_id surfaces AgentKernelError, not sqlite3.IntegrityError.""" + store = SQLiteTraceStore(tmp_path / "a.db", secret=SECRET) + store.record(_trace("act-dup")) + with pytest.raises(AgentKernelError, match="already present"): + store.record(_trace("act-dup")) + # The store stays usable and its chain intact after the rejected write. + assert [t.action_id for t in store.list_all()] == ["act-dup"] + assert store.verify_chain().ok + + +def test_open_non_sqlite_file_raises_typed_error(tmp_path: Path) -> None: + """Opening a non-SQLite file (e.g. a JSONL store) surfaces a typed error.""" + bogus = tmp_path / "traces.jsonl" + bogus.write_text('{"seq": 0}\n', encoding="utf-8") + with pytest.raises(AgentKernelError, match="not a valid SQLite trace store"): + SQLiteTraceStore(bogus, secret=SECRET) + + def test_prune_accepts_naive_datetime_as_utc(tmp_path: Path) -> None: store = SQLiteTraceStore(tmp_path / "a.db", secret=SECRET) base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)