diff --git a/CHANGELOG.md b/CHANGELOG.md index c89903f..078eff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Capability-token lifecycle hardening.** A grouped pass over grant TTL, + invoke-time enforcement, and key rotation: + - **Signing-key rotation (#185).** `HMACTokenProvider` accepts a `{key_id: + secret}` `KeyRing` and an `active_key_id`; a token issued under a + previous key still verifies during the rotation's overlap window, and an + unknown `key_id` fails closed as `TokenInvalid`. A single `secret=...` + (or none) keeps working exactly as before. New optional + `WEAVER_KERNEL_SECRETS` env var (JSON `{key_id: secret}`) takes + precedence over the legacy `WEAVER_KERNEL_SECRET`. + - **Typed errors from `CapabilityToken.from_dict` (#200).** Malformed + input (missing field, non-string field, invalid timestamp, non-object + `constraints`) now raises `TokenInvalid` with a descriptive message + instead of a bare `KeyError`/`ValueError`. Valid round-trips are + unchanged. + - **Per-grant TTL (#203).** `Kernel.grant_capability(..., ttl_s=...)` sets + a token's lifetime per grant. `DefaultPolicyEngine(max_ttl_s={...})` + validates it via a new, protocol-optional `resolve_ttl()` method — an + excessive or non-positive `ttl_s` is denied (`PolicyDenied`), never + silently clamped. Omitting `ttl_s` preserves today's default TTL exactly. + - **Signed argument-level constraints (#183).** A token's + `constraints["args"]` (`allowed_keys`, `pinned`, `prefix`) is enforced by + `Kernel.invoke()` before a driver runs and before budget is reserved, + raising `TokenScopeError` with a stable reason code and an audited + `"deny"` trace on violation. Dry-run predicts the identical outcome. + `DeclarativePolicyEngine` needed no code changes — `constraints` is + already a free-form mapping — see the new example rule in + `examples/policies/default.{yaml,toml}`. + - **Opt-in per-invocation rate limiting (#170).** `Kernel(invoke_rate_limits= + {SafetyClass: (limit, window_s)})` enforces a second, invoke-time sliding + window independent of the existing grant-time limit — default off, so + behavior is unchanged unless configured. A violation raises the new + `RateLimitExceeded` with a stable reason code; `dry_run=True` never + consumes it. + - **ADR: token signing format evolution (#224).** `docs/adr/0001-token-signing-evolution.md` + evaluates HMAC (status quo), macaroon-style caveat chaining, and Biscuit + against this kernel's invariants and constraints — recommending status + quo + re-issuance now, macaroon-style chaining as the future path if an + offline-delegation need materializes, and explicitly deferring Biscuit + (mandatory dependency, weakened default revocation posture, no current + consumer). Investigation only — no code or dependency change. - **Fail-closed driver execution.** A grouped pass over the invocation path so the kernel's "controlled, audited execution" promise (I-02) holds on *every* exit, not just the happy path: @@ -115,6 +155,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Companion: `examples/trace_replay_demo.py`. ### Changed +- **`CapabilityToken`'s signed payload now includes `key_id` (#185).** + `_signable_payload()` adds `key_id` to the HMAC input for every token, + including single-key (`key_id=""`) providers. **Breaking for tokens issued + by pre-upgrade code:** a token signed before this change fails verification + as `TokenInvalid` after upgrading, even under the same secret, since the + signed payload shape differs. Tokens are short-lived (1 hour by default) — + expect at most one TTL window of re-grant errors immediately after a + rolling deploy. Treated the same as the 0.10.0 import rename: an accepted + break at this project's current alpha stage, not a compatibility shim. - **CI aligned with `make ci` and hardened (#209, #210, #232).** The `ci.yml` test job now invokes the Makefile targets (`fmt-check`/`lint`/`type`/`test`/ `example`) instead of re-implementing them, so the local gate and CI cannot diff --git a/docs/adr/0001-token-signing-evolution.md b/docs/adr/0001-token-signing-evolution.md new file mode 100644 index 0000000..1736205 --- /dev/null +++ b/docs/adr/0001-token-signing-evolution.md @@ -0,0 +1,217 @@ +# ADR 0001: Token signing format evolution (HMAC vs. Biscuit vs. macaroon-style chaining) + +- **Status:** Accepted +- **Date:** 2026-07 +- **Related:** #224 (this investigation), #129 (delegated/attenuated grants), + #103 (production-hardening roadmap), #185 (signing-key rotation, shipped + alongside this ADR) + +## Context + +`CapabilityToken` is signed with a single symmetric secret (HMAC-SHA256, now +with key-id-based rotation support — #185). Two roadmap directions strain +that design: + +1. **Delegated, attenuated grants (#129)** want an agent holding a token to + derive a *narrower* token for a sub-agent, offline, without contacting the + kernel — the core trick behind macaroons and Biscuit. +2. **Federation across trust boundaries** (`federation_discovery.py`'s + pairwise-shared-secret manifest signing) wants a peer to *verify* a token + without holding the secret that signed it — which shared-secret HMAC + structurally cannot do. + +This ADR evaluates whether the kernel's token format should change to +support one or both, evaluates against the kernel's actual invariants and +constraints (not generic crypto-library shopping), and produces a +recommendation. Per #224's scope, this is **investigation only** — no +production code or dependency changes. + +## Evaluation criteria + +Derived from `docs/agent-context/invariants.md` and this repo's stated +engineering constraints (`AGENTS.md`): + +| Criterion | Why it matters here | +|---|---| +| **I-06 binding** | Any replacement must still bind `principal_id + capability_id + constraints`, tamper-evidently, with no cross-principal reuse. | +| **Minimal-dependency policy** | `AGENTS.md`: "No new dependencies without justification... runtime dep list is intentionally minimal." A format needing a mandatory third-party library conflicts with this unless gated behind an optional extra. | +| **Determinism** | `AGENTS.md`: "No randomness in matching, routing, or summarization. Deterministic outputs always." A policy language embedded in the token (e.g. Datalog) must not introduce non-deterministic evaluation. | +| **Token size / verification cost** | Tokens are created and verified on every `grant_capability`/`invoke` call — this is a hot path, not a cold one. | +| **Revocation interaction** | The kernel's revocation model (`RevocationStoreProtocol`, `HMACTokenProvider.revoke`) assumes the issuer can always invalidate a `token_id`. Offline-verifiable formats weaken this by design (that's the point of attenuation), so the *combination* matters. | +| **Auditability / `explain()` transparency** | `Kernel.explain_denial()` and `DenialExplanation` promise a human-readable "why" for every decision. A policy language embedded in tokens must not become an opaque black box relative to today's rule-trace transparency. | +| **0.x migration cost** | The project is pre-1.0 (`pyproject.toml`: `Development Status :: 3 - Alpha`) and has already taken breaking changes at this stage (0.10.0 import rename; #185's token-payload shape change) — a migration is *acceptable* here if the destination is clearly better, but gratuitous churn is not. | + +## Options considered + +### A. Status quo (HMAC-SHA256) + re-issuance-based attenuation + +Keep the current signed-shared-secret format. "Attenuation" is achieved by +having the kernel mint a **new**, narrower token on request (a delegation +request is just another `grant_capability` call with tighter `constraints`), +rather than letting a token holder derive one offline. + +- **I-06 binding:** Unchanged — already satisfied today. +- **Dependencies:** None. Pure stdlib (`hmac`, `hashlib`), as today. +- **Determinism:** Unaffected. +- **Size / verify cost (measured on this repo's current implementation):** a + representative token (`constraints={"max_rows": 50, "allowed_fields": + [...]}`) serializes to **401 bytes** as JSON; `verify()` averages **~12.5 + µs/call** and `issue()` **~20.5 µs/call** on a single core in this sandbox + (`python3 -m timeit`-style loop, 20 000 iterations — see measurement script + in this ADR's PR). Both are dominated by UUID generation and JSON + serialization, not the HMAC itself. +- **Revocation:** Fully compatible — this is exactly what `RevocationStoreProtocol` + already assumes. +- **Auditability:** No change — `explain()`/`DenialExplanation` already cover + every decision path. +- **Migration cost:** None. +- **Delegation cost:** Every delegation is a round-trip to the issuing + kernel. For multi-agent delegation chains that stay within one kernel + process (the common case today — see `docs/architecture.md`), this is a + function call, not a network call, so the "offline" benefit macaroons/Biscuit + offer is largely moot *for this deployment shape*. It only matters once + #227 (remote kernel mode) ships and delegation crosses a process boundary. + +### B. Macaroon-style HMAC caveat chaining + +Keep symmetric HMAC, but let a token holder append "caveats" (additional +restrictions) and re-derive a new HMAC via `HMAC(K_n, caveat_n)` chaining, +without contacting the issuer, per the original macaroon construction. + +- **I-06 binding:** Preserved *if* every caveat is a strict narrowing (harder + to prevent structurally than it sounds — a caveat language needs its own + "monotonic narrowing" proof, which is exactly the kind of subtle security + invariant this codebase's own review checklist flags as a common mistake + class). +- **Dependencies:** None required — chaining is stdlib `hmac`/`hashlib`, same + as today. +- **Determinism:** Preserved, if the caveat predicate language is kept to the + same tiny, deterministic vocabulary `constraints["args"]` (#183) already + established (`allowed_keys`/`pinned`/`prefix` — no free-form expressions). +- **Size / verify cost:** Grows linearly with delegation depth (one HMAC + chain link per caveat) but each link is a cheap HMAC, so verification stays + fast even several levels deep — no asymmetric-crypto cost. +- **Revocation:** The classic macaroon weakness applies: revoking the root + token must be checked at verify time regardless of caveat depth (doable — + our `RevocationStoreProtocol` already keys on `token_id`, so this is a + workable fit), but a compromised *intermediate* delegate can still mint + further-caveated tokens the issuer never sees, which is a real reduction in + audit visibility versus "every grant is a `grant_capability()` call." +- **Auditability:** Weaker than option A — an offline-derived caveat chain + bypasses `record_denial_trace`/`ActionTrace` entirely until the final + token is presented for `verify()`, at which point the whole chain must be + replayed to explain a decision. This directly cuts against I-02's + "every execution... auditable" spirit for the delegation step itself + (the final invocation is still audited normally). +- **Migration cost:** Additive — `CapabilityToken` would need a `caveats: + list[...]` field and `verify()` would need to fold the chain. Backward + compatible with #185's `key_id` field (the root key is just the entry in + the `KeyRing`). + +### C. Biscuit (public-key, Datalog-based attenuation) + +Adopt Biscuit tokens: public-key-signed (Ed25519), attenuable via appended +Datalog blocks, verifiable without the signing key. + +- **I-06 binding:** Preserved — Biscuit's whole design is capability + attenuation with a documented monotonic-restriction model, more rigorously + specified than a hand-rolled macaroon caveat language would be. +- **Dependencies:** Requires the `biscuit-python` third-party package (or + equivalent) as a **mandatory** import wherever Biscuit tokens are verified. + This is the sharpest conflict with `AGENTS.md`'s minimal-dependency policy; + it would need to live behind a new optional extra (mirroring `[mcp]`, + `[otel]`, `[tiktoken]`), which is architecturally fine but means every + consumer of Biscuit-format tokens must opt in explicitly — no + "just works" default the way HMAC does today. +- **Determinism:** Datalog evaluation is deterministic by specification, but + it is a real embedded policy *language* — a meaningfully bigger surface + than today's flat dict-based `constraints`, and a bigger gap for + `explain()` to bridge (a Datalog proof trace is not the same shape as + today's `PolicyDecisionTrace`/`FailedCondition` model without new + translation code). +- **Size / verify cost:** Ed25519 signatures and Datalog block encoding are + categorically larger and slower than a 32-byte HMAC-SHA256 digest — by + design (public-key crypto has this cost everywhere). This repo has no + currently-committed number for Biscuit specifically (would require adding + the dependency to measure, which #224 explicitly says not to do in this + PR); qualitatively, expect verification cost at least an order of + magnitude above the ~12.5 µs HMAC baseline measured above, purely from + Ed25519 signature verification, before Datalog evaluation cost is added. +- **Revocation:** Structurally the hardest fit. Public-key offline + verification is the *opposite* of "the issuer can always invalidate," so + Biscuit's own guidance leans on short TTLs + revocation lists checked at + the edge — which this kernel already does (`sweep_revocations`, + `RevocationStoreProtocol`), but only for a verifier that has the *ability* + to check revocation state remotely, which an offline verifier (the whole + point of Biscuit) may not have. +- **Auditability:** A Biscuit proof is more powerful than today's rule trace + but is a different representation entirely; `explain()` would need a real + translation layer to keep parity, not a small patch. +- **Migration cost:** Highest of the three. `TokenProvider` is a `Protocol` + (I-06 notes the seam is load-bearing) so a `BiscuitTokenProvider` *can* + slot in without touching `Kernel`, but every downstream consumer + (federation manifest signing, revocation store, CLI `audit` inspection, + trace export) currently assumes the HMAC shape and would need a compatible + view or a hard cutover. + +## Recommendation + +**Adopt Option A now (status quo + re-issuance), keep Option B (macaroon-style +chaining) as the documented evolution path for #129 if/when in-process +delegation genuinely needs to cross a process or trust boundary offline, and +reject Option C (Biscuit) for the foreseeable future.** + +Rationale: + +- #129's delegation need is satisfiable today via re-issuance + (`grant_capability` with narrower `constraints`) for the deployment shape + this kernel actually has (single process, in-memory or same-process + registry) — see `docs/architecture.md`. Building offline attenuation ahead + of an actual offline-delegation requirement would be exactly the kind of + speculative "future-proofing" `AGENTS.md`'s review checklist warns against. +- Biscuit's mandatory-dependency, weakened-revocation, and + `explain()`-parity costs are real and immediate; the benefit (offline, + cross-trust-boundary verification) has no current consumer in this + codebase. Revisit if/when #227 (remote kernel mode) or genuine + cross-organization federation of live tokens (not just manifest + advertising, which already has its own signature scheme in + `federation_discovery.py`) becomes a committed feature. +- Macaroon-style chaining is the credible middle ground — no new dependency, + same crypto primitive already in the codebase — but should wait until a + concrete offline-delegation use case exists, so the caveat vocabulary can + be scoped to that need rather than designed speculatively. #183's + `constraints["args"]` vocabulary is deliberately shaped to be reusable as + a future caveat predicate language if that day comes. + +## Migration sketch (if Option B is later adopted) + +1. Add `caveats: list[Caveat]` to `CapabilityToken` (additive, empty by + default — no behavior change for existing tokens). +2. Extend `_signable_payload()` to fold caveats into the HMAC chain + (`HMAC(K_n, caveat_n || previous_signature)`). +3. Add `Kernel.attenuate(token, *, args_constraint=...) -> CapabilityToken` + as a **pure, local** operation (no kernel round-trip) that appends a + caveat and re-derives the chained signature. +4. `HMACTokenProvider.verify()` folds the chain before the existing + principal/capability checks — no change to `RevocationStoreProtocol` + (revocation still keys on the root `token_id`). +5. Ship behind no new extra (stdlib-only) but bump `CapabilityToken`'s + schema version marker if one exists by then, so old/new payload shapes + are distinguishable during a rollout. + +## Explicit rejection rationale for Biscuit + +Rejected **for now**, not permanently: revisit only when a concrete feature +(remote kernel, cross-org live-token federation) needs offline, +non-issuer verification badly enough to justify the mandatory dependency, +the `explain()` translation work, and the weakened default revocation +posture. Until then it fails the minimal-dependency and revocation criteria +against a benefit this codebase does not yet have a consumer for. + +## Follow-ups (comments left on referenced issues) + +- #129: reference this ADR; delegation should ship as re-issuance + (Option A) unless/until an offline requirement is identified. +- #103: reference this ADR under the "token signing/encryption" roadmap + item — the roadmap's asymmetric-crypto question is answered "not yet, + and here's why" rather than left open. diff --git a/docs/capabilities.md b/docs/capabilities.md index 56b7278..5327fec 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -149,6 +149,14 @@ path if you change either: `"summary"` — see `firewall/transform.py`). Dry-run downgrades the same way, so non-admin callers cannot probe for raw-mode availability via `DryRunResult`. +4. **Signed argument constraints are checked identically.** A token's + `constraints["args"]` (`allowed_keys`/`pinned`/`prefix`) is validated the + same way at dry-run as at real-invoke, so a dry-run's predicted outcome + never diverges from what `invoke()` would actually do — see + [docs/security.md](security.md#signed-argument-level-constraints-183). + The opt-in per-invocation rate limit is the one exception: dry-run never + checks or consumes it (see + [docs/security.md](security.md#per-invocation-rate-limiting-170)). The driver's `execute()` is never called in dry-run, so the mode is free of side effects regardless of driver type (`InMemoryDriver`, `HTTPDriver`, @@ -188,6 +196,28 @@ present"), and `min_justification` (minimum stripped length). On `allow`, the rule's `constraints` are merged into the resulting `PolicyDecision`. On `deny`, `reason` is embedded in the raised `PolicyDenied`. +`constraints` is a free-form mapping, so a rule can attach any signed +constraint the kernel understands — not just `max_rows`/`allowed_fields`, but +also a nested `args` block for [signed argument-level +constraints](security.md#signed-argument-level-constraints-183): + +```yaml +- name: allow-support-scoped-update + action: allow + match: + safety_class: [WRITE] + roles: [support] + min_justification: 10 + constraints: + args: + allowed_keys: [ticket_id, status] +``` + +No parser or engine change is needed for this — `constraints` already flows +into the issued token unmodified. See +[`examples/policies/default.yaml`](../examples/policies/default.yaml) for the +full worked rule. + The DSL has no negation/missing-attribute operator today, so a policy that should deny "when an attribute is missing" should be expressed as an allow rule requiring the attribute paired with `default: deny`. See @@ -198,6 +228,17 @@ worked example. extra. `import weaver_kernel` always works; calling `from_yaml` / `from_toml` without the parser installed raises `PolicyConfigError` with an install hint. +## Per-grant TTL + +```python +grant = kernel.grant_capability(request, principal, justification="...", ttl_s=60.0) +``` + +`ttl_s` requests a token lifetime shorter (or longer, if policy allows) than +the fixed default. Omit it for unchanged behavior. See [docs/security.md — +Per-grant TTL](security.md#per-grant-ttl-203) for the policy-side +`max_ttl_s` validation and denial semantics. + ## Denial explanations When a capability call is denied, `Kernel.explain_denial(request, principal, diff --git a/docs/security.md b/docs/security.md index 3e136f1..3af929f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -18,17 +18,135 @@ | Handle scope escape (expand exceeds grant) | Handles persist grant constraints; `HandleStore.expand` rechecks `max_rows`, `allowed_fields`, `scope`, and principal binding (#76) | | Sensitive data reaching the audit log via args/errors | `ActionTrace.args` and driver `error` text are run through the firewall redactor for **every** capability; memory payloads (`payload`/`content`/`value`/`memory`/`text`/`body`) are additionally stripped wholesale for `memory.*` capabilities (#75, #172) | | Scanned content / raw result reaching audit log | `ActionTrace.result_summary` is built only from the post-firewall `Frame` (counts and flags, never raw driver data), so the audit trail records an invocation's outcome without re-introducing the data the firewall removed | +| Runaway loop draining a single grant (agent loops `invoke()` on one token) | Opt-in, default-off per-invocation rate limit (`Kernel(invoke_rate_limits=...)`), enforced before every driver call, independent of the grant-time limit (#170) | +| Over-broad argument use within an authorized capability (e.g. `files.read` used outside an approved path) | Signed `constraints["args"]` (`allowed_keys`, `pinned`, `prefix`) enforced before the driver runs; tampering invalidates the token's HMAC (#183) | +| Compromised or rotated signing secret invalidating every live token at once | `HMACTokenProvider` accepts a `{key_id: secret}` `KeyRing` with one active key; retired keys still verify during a rotation's overlap window (#185) | ## Token scopes A `CapabilityToken` binds: - `capability_id` — which capability is authorized - `principal_id` — who the token was issued to -- `constraints` — max_rows, allowed_fields, etc. (signed into the token) -- `expires_at` — validity window +- `constraints` — `max_rows`, `allowed_fields`, `args` (see below), etc. (signed into the token) +- `expires_at` — validity window, from a fixed default or a per-grant `ttl_s` (see below) +- `key_id` — which signing key produced the signature (see Signing-key rotation) Any change to these fields invalidates the HMAC signature. +## Per-grant TTL (#203) + +`Kernel.grant_capability(..., ttl_s=...)` lets a caller request a token +lifetime shorter (or, if permitted, longer) than the token provider's fixed +default (1 hour) — least privilege is temporal as well as scoped. `ttl_s` is +optional; omitting it preserves today's behavior exactly. + +A `DefaultPolicyEngine` configured with `max_ttl_s={SafetyClass.WRITE: 300.0, +...}` validates the request via an optional `resolve_ttl()` method: a +non-positive `ttl_s` or one exceeding the configured maximum is **denied** +(`PolicyDenied`, `reason_code="ttl_exceeds_max"` or `"invalid_constraint"`) — +never silently clamped, so a caller is never handed a shorter-lived token than +it asked for without knowing it. `resolve_ttl` is deliberately *not* part of +the `PolicyEngine` protocol: a policy engine that doesn't implement it simply +leaves `ttl_s` unvalidated, so adding this feature never breaks a third-party +engine (the same non-breaking pattern used for `ExplainingPolicyEngine`). + +Very short TTLs can expire mid-task for slow tools — consider pairing a short +`ttl_s` with a signed `invoke_timeout_s` constraint (see `docs/capabilities.md`) +so the token outlives the deadline it's meant to bound. + +## Per-invocation rate limiting (#170) + +`DefaultPolicyEngine`'s sliding-window rate limit (see Security disclaimers, +below) applies only at grant time: once issued, a token is unlimited-use until +it expires. `Kernel(invoke_rate_limits={SafetyClass.READ: (60, 60.0), ...})` +adds an **opt-in, default-off** second limit enforced on the invoke path +itself, before every driver call, so an agent that grants once and loops +`invoke()` cannot exceed the configured rate regardless of the token's TTL. + +- Disabled by default — passing nothing preserves current behavior exactly. +- Keyed by `(principal_id, capability_id)`, the same key shape as the + grant-time limiter, and backed by the same `RateLimiter` primitive (inject a + clock via `Kernel(invoke_rate_limiter=RateLimiter(clock=...))` for tests). +- A violation raises `RateLimitExceeded` (`reason_code="invoke_rate_limited"`) + and records an audited `"deny"` trace — never a silent drop. +- `dry_run=True` never checks or consumes this limit. +- Like the grant-time limiter, state is in-memory and per-process only — no + distributed or persistent rate-limit state across replicas. + +## Signed argument-level constraints (#183) + +A capability token authorizes a *capability*, but by default any arguments — +a grant for `files.read` permits reading anything the driver can reach for +the token's lifetime. `constraints["args"]`, signed into the token at grant +time, narrows that down to a small, deterministic vocabulary evaluated +identically by `Kernel.invoke()` and dry-run (never a general expression +language, to keep evaluation reviewable and side-effect-free): + +| Key | Effect | +|-----|--------| +| `allowed_keys` | A list of argument names; any other key in `args` is rejected. | +| `pinned` | A `{key: value}` map; a **present** key's value must equal the pinned value exactly. | +| `prefix` | A `{key: prefix}` map; a **present** key's value must be a string starting with the prefix. | + +A key absent from the invocation's `args` never violates `pinned`/`prefix` — +those constrain a value the caller *chose to pass*, not argument presence. +All three operate on top-level keys only (v1 scope). A violation raises +`TokenScopeError` (`reason_code="arg_constraint_violation"`) **before** the +driver executes and before budget is reserved, with an audited `"deny"` +trace. Because the constraint lives inside the signed payload, tampering with +it (like tampering with any other constraint) invalidates the token's HMAC. + +`DeclarativePolicyEngine` needs no code changes to attach `args` constraints — +`constraints` is already a free-form mapping merged into the issued token, so +a YAML/TOML rule can include a nested `args` block directly (see +`examples/policies/default.yaml`/`.toml`). + +## Signing-key rotation (#185) + +`HMACTokenProvider` can hold more than one named secret so +`WEAVER_KERNEL_SECRET` can be rotated without invalidating every outstanding +token at once: + +```python +provider = HMACTokenProvider( + secrets={"2026-a": "old-secret", "2026-b": "new-secret"}, + active_key_id="2026-b", +) +``` + +- New tokens are always signed under `active_key_id`, whose id (never the + secret) is stamped into the token's `key_id` field — inside the signed + payload, so tampering with it invalidates the signature like any other + field. +- `verify()` resolves the secret by the token's own `key_id`. A `key_id` that + isn't in the configured set fails closed as `TokenInvalid` — verification + never falls back to a different secret. +- Verifying a token signed under a non-active (retired-but-still-configured) + key logs `token_verified_non_active_key` at INFO with the key id (never the + secret), so an operator can tell when it's safe to drop the old key + entirely from `secrets`. +- A single `secret=...` (or no argument at all) keeps working exactly as + before — that's a one-key ring under `key_id=""`. +- Precedence for the implicit (no `secrets=` argument) case: + `WEAVER_KERNEL_SECRETS` (a JSON object of `{key_id: secret}`) takes priority + over the legacy single `WEAVER_KERNEL_SECRET` env var. + +**Recommended rotation procedure:** add the new key alongside the old one +(`secrets={"old": ..., "new": ...}`, `active_key_id` still `"old"`) → deploy → +flip `active_key_id` to `"new"` → deploy → after the longest outstanding +token's TTL has elapsed, remove `"old"` from `secrets` entirely. + +**Breaking change for in-flight tokens across this upgrade.** The `key_id` +field is part of the signed payload for *every* token, including ones issued +by a single-secret, pre-#185 provider (`key_id=""`). A token issued by +code *before* this change was signed over a payload shape that didn't include +`key_id` at all, so it will fail verification as `TokenInvalid` after +upgrading — even under the same secret. Given the project's current alpha +status (see `CHANGELOG.md`), this is treated the same as the 0.10.0 import +rename: a clean break, not a compatibility shim. Tokens are short-lived +(1 hour by default) — expect at most one TTL window of "please re-grant" +errors immediately after a rolling deploy. + ## Confused deputy prevention Consider an agent that obtains a token for `billing.list_invoices` then passes it to a different agent. The second agent cannot use it because `verify()` checks that `token.principal_id == expected_principal_id`. @@ -169,8 +287,10 @@ per revoked token. Both in-memory structures are bounded: secret longer than that bound is force-committed and may be severed at the cut, so an extremely long unbroken token can escape per-segment detection — a deliberate memory-vs-safety trade. -- Rate limiting is enforced per `(principal_id, capability_id)` pair using a sliding window. - Default limits: 60 READ / 10 WRITE / 2 DESTRUCTIVE invocations per 60-second window. - Principals with the `"service"` role receive 10× the default limits. Limits are - configurable via `DefaultPolicyEngine(rate_limits=...)`. There is no distributed or - persistent rate-limit state — limits reset on process restart. +- Grant-time rate limiting is enforced per `(principal_id, capability_id)` pair + using a sliding window. Default limits: 60 READ / 10 WRITE / 2 DESTRUCTIVE + invocations per 60-second window. Principals with the `"service"` role + receive 10× the default limits. Limits are configurable via + `DefaultPolicyEngine(rate_limits=...)`. An optional, separate invoke-time + limit is also available — see "Per-invocation rate limiting" above. Neither + has distributed or persistent state — both reset on process restart. diff --git a/examples/policies/default.toml b/examples/policies/default.toml index 1d62354..e9f1c2c 100644 --- a/examples/policies/default.toml +++ b/examples/policies/default.toml @@ -66,3 +66,19 @@ action = "deny" reason = "SECRETS access is restricted to service-role principals" [rules.match] sensitivity = ["SECRETS"] + +# Argument-level constraints (#183): `constraints` is a free-form table +# merged into the issued token, so a declarative rule can pin down *which* +# arguments an invocation may use — not just which capability. Here, a +# support role may update a ticket's status field only, never arbitrary +# fields, without a separate capability per allowed field. +[[rules]] +name = "allow-support-scoped-update" +action = "allow" +[rules.match] +safety_class = ["WRITE"] +sensitivity = ["NONE"] +roles = ["support"] +min_justification = 10 +[rules.constraints.args] +allowed_keys = ["ticket_id", "status"] diff --git a/examples/policies/default.yaml b/examples/policies/default.yaml index 00145d0..938fd3b 100644 --- a/examples/policies/default.yaml +++ b/examples/policies/default.yaml @@ -62,3 +62,19 @@ rules: match: sensitivity: [SECRETS] reason: "SECRETS access is restricted to service-role principals" + + # Argument-level constraints (#183): `constraints` is a free-form mapping + # merged into the issued token, so a declarative rule can pin down *which* + # arguments an invocation may use — not just which capability. Here, a + # support role may update a ticket's status field only, never arbitrary + # fields, without a separate capability per allowed field. + - name: allow-support-scoped-update + action: allow + match: + safety_class: [WRITE] + sensitivity: [NONE] + roles: [support] + min_justification: 10 + constraints: + args: + allowed_keys: [ticket_id, status] diff --git a/src/weaver_kernel/__init__.py b/src/weaver_kernel/__init__.py index 94a3b39..788d50c 100644 --- a/src/weaver_kernel/__init__.py +++ b/src/weaver_kernel/__init__.py @@ -56,7 +56,7 @@ from weaver_kernel import ( AgentKernelError, TokenExpired, TokenInvalid, TokenScopeError, TokenRevoked, - PolicyDenied, PolicyConfigError, + PolicyDenied, PolicyConfigError, RateLimitExceeded, DriverError, FirewallError, AdapterParseError, BudgetExhausted, BudgetConfigError, CapabilityNotFound, CapabilityAlreadyRegistered, @@ -95,6 +95,7 @@ NamespaceNotFound, PolicyConfigError, PolicyDenied, + RateLimitExceeded, TokenExpired, TokenInvalid, TokenRevoked, @@ -155,6 +156,7 @@ from .policy import DefaultPolicyEngine, ExplainingPolicyEngine, PolicyEngine from .policy_dsl import DeclarativePolicyEngine, PolicyMatch, PolicyRule from .policy_reasons import AllowReason, DenialReason +from .rate_limit import RateLimiter from .registry import CapabilityRegistry from .replay import ( DecisionDiff, @@ -251,6 +253,7 @@ "NamespaceNotFound", "PolicyConfigError", "PolicyDenied", + "RateLimitExceeded", "TokenExpired", "TokenInvalid", "TokenRevoked", @@ -277,6 +280,7 @@ "PolicyEngine", "PolicyMatch", "PolicyRule", + "RateLimiter", # tokens "HMACTokenProvider", # router diff --git a/src/weaver_kernel/_secrets.py b/src/weaver_kernel/_secrets.py index 3a81ca7..5daa7f2 100644 --- a/src/weaver_kernel/_secrets.py +++ b/src/weaver_kernel/_secrets.py @@ -11,16 +11,29 @@ from __future__ import annotations +import json import logging import os import secrets import threading +from typing import Any + +from .errors import AgentKernelError logger = logging.getLogger(__name__) SECRET_ENV_VAR = "WEAVER_KERNEL_SECRET" """Environment variable holding the HMAC secret used for tokens and audit chains.""" +SECRETS_ENV_VAR = "WEAVER_KERNEL_SECRETS" +"""Environment variable holding a JSON object of ``{key_id: secret}`` for +signing-key rotation (#185). Takes precedence over :data:`SECRET_ENV_VAR` +when set. A rotation adds the new key alongside the old one, switches which +key new tokens are issued under (see +:class:`~weaver_kernel.tokens.HMACTokenProvider`'s ``active_key_id``), and +retires the old key only after the longest outstanding token's TTL elapses. +""" + _DEV_SECRET: str | None = None _DEV_SECRET_LOCK = threading.Lock() @@ -62,3 +75,41 @@ def resolve_hmac_secret(explicit: str | None = None) -> str: if explicit: return explicit return _get_secret() + + +def resolve_hmac_secrets_map() -> dict[str, str]: + """Resolve a ``key_id -> secret`` map for multi-key signing/rotation. + + Precedence: + + 1. :data:`SECRETS_ENV_VAR` (``WEAVER_KERNEL_SECRETS``) — a JSON object + mapping each ``key_id`` to its secret, e.g. ``{"2026-a": "...", + "2026-b": "..."}``. + 2. A single legacy key under ``key_id=""``, resolved the same way as + :func:`resolve_hmac_secret` (``WEAVER_KERNEL_SECRET``, else a + process-lived dev fallback with a one-time warning). + + Returns: + A non-empty ``{key_id: secret}`` mapping. + + Raises: + AgentKernelError: If :data:`SECRETS_ENV_VAR` is set but is not a JSON + object of string keys to string values. + """ + raw = os.environ.get(SECRETS_ENV_VAR) + if not raw: + return {"": _get_secret()} + try: + parsed: Any = json.loads(raw) + except json.JSONDecodeError as exc: + raise AgentKernelError(f"{SECRETS_ENV_VAR} must be valid JSON: {exc}") from exc + if not isinstance(parsed, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in parsed.items() + ): + raise AgentKernelError( + f"{SECRETS_ENV_VAR} must be a JSON object mapping string key ids to " + f"string secrets, got {raw!r}." + ) + if not parsed: + raise AgentKernelError(f"{SECRETS_ENV_VAR} must not be an empty object.") + return parsed diff --git a/src/weaver_kernel/_token_signing.py b/src/weaver_kernel/_token_signing.py new file mode 100644 index 0000000..a2c05f1 --- /dev/null +++ b/src/weaver_kernel/_token_signing.py @@ -0,0 +1,261 @@ +"""Multi-key HMAC signing and typed-error token deserialization. + +Split out of :mod:`tokens` to keep it within its ratchet ceiling +(AGENTS.md). :class:`KeyRing` backs +:class:`~weaver_kernel.tokens.HMACTokenProvider`'s key-rotation support +(#185); :func:`parse_token_dict` is the typed-error +:meth:`~weaver_kernel.tokens.CapabilityToken.from_dict` body (#200). +""" + +from __future__ import annotations + +import datetime +import hashlib +import hmac +import logging +from typing import TYPE_CHECKING, Any + +from ._secrets import resolve_hmac_secrets_map +from .errors import AgentKernelError, TokenExpired, TokenInvalid, TokenRevoked, TokenScopeError +from .stores import RevocationStoreProtocol + +if TYPE_CHECKING: # pragma: no cover + from .tokens import CapabilityToken + +logger = logging.getLogger("weaver_kernel.tokens") + + +class KeyRing: + """A set of named HMAC secrets, one of which is active for new issuance. + + Verification resolves a token's declared ``key_id`` against the full + set — including keys retired from active issuance — so a token signed + under a previous key still verifies during a rotation's overlap window. + An unknown ``key_id`` fails closed (:class:`~weaver_kernel.TokenInvalid` + via :meth:`secret_for`), never silently falling back to another secret. + """ + + def __init__( + self, + secrets: dict[str, str] | None = None, + *, + active_key_id: str | None = None, + legacy_secret: str | None = None, + ) -> None: + """Build a key ring. + + Args: + secrets: Mapping of ``key_id`` to HMAC secret, for multi-key + rotation. When omitted, falls back to *legacy_secret*. + active_key_id: The key id new tokens are signed with. Must be a + key in *secrets*. Defaults to the sole key when exactly one + is configured; required when more than one is configured. + legacy_secret: Single-secret convenience for the pre-rotation + ``HMACTokenProvider(secret=...)`` shape — used only when + *secrets* is not given. ``None`` resolves from the + environment (``WEAVER_KERNEL_SECRETS``, then + ``WEAVER_KERNEL_SECRET``, then a dev fallback). + + Raises: + AgentKernelError: If *active_key_id* is not a key in the + resolved secrets, or more than one secret is configured and + *active_key_id* was not given. + """ + if secrets: + self._secrets: dict[str, str] = dict(secrets) + elif legacy_secret is not None: + self._secrets = {"": legacy_secret} + else: + self._secrets = resolve_hmac_secrets_map() + + if active_key_id is not None: + if active_key_id not in self._secrets: + raise AgentKernelError( + f"active_key_id {active_key_id!r} is not a key in the configured secrets." + ) + self._active_key_id = active_key_id + elif len(self._secrets) == 1: + self._active_key_id = next(iter(self._secrets)) + else: + raise AgentKernelError( + "active_key_id is required when more than one secret is configured." + ) + + @property + def active_key_id(self) -> str: + """The key id new tokens are signed under.""" + return self._active_key_id + + def is_active(self, key_id: str) -> bool: + """Return whether *key_id* is the current active signing key.""" + return key_id == self._active_key_id + + def secret_for(self, key_id: str) -> bytes: + """Resolve the signing secret bytes for *key_id*. + + Raises: + TokenInvalid: If *key_id* is not a known key in this ring — + fails closed rather than falling back to another secret. + """ + secret = self._secrets.get(key_id) + if secret is None: + raise TokenInvalid(f"Unknown signing key id {key_id!r}.") + return secret.encode() + + +def parse_token_dict(data: dict[str, Any]) -> CapabilityToken: + """Reconstruct a :class:`~weaver_kernel.tokens.CapabilityToken` from a dict. + + Tokens cross process boundaries by design, so a malformed dict is an + expected input class, not a programming error — every failure mode below + raises :class:`TokenInvalid` instead of a bare ``KeyError``/``ValueError``. + + Args: + data: The token's serialized form, as produced by + :meth:`~weaver_kernel.tokens.CapabilityToken.to_dict`. + + Raises: + TokenInvalid: If a required field is missing, a required field has + the wrong type, a timestamp field is not a valid ISO-8601 + string, or ``constraints`` is present but not an object. + """ + from .tokens import CapabilityToken # lazy: avoid tokens<->_token_signing cycle + + try: + token_id = data["token_id"] + capability_id = data["capability_id"] + principal_id = data["principal_id"] + issued_at_raw = data["issued_at"] + expires_at_raw = data["expires_at"] + except KeyError as exc: + raise TokenInvalid(f"malformed token payload: missing field {exc.args[0]!r}") from exc + + for field_name, value in ( + ("token_id", token_id), + ("capability_id", capability_id), + ("principal_id", principal_id), + ): + if not isinstance(value, str): + raise TokenInvalid(f"malformed token payload: field {field_name!r} must be a string") + + try: + issued_at = datetime.datetime.fromisoformat(issued_at_raw) + except (TypeError, ValueError) as exc: + raise TokenInvalid( + "malformed token payload: invalid timestamp in field 'issued_at'" + ) from exc + try: + expires_at = datetime.datetime.fromisoformat(expires_at_raw) + except (TypeError, ValueError) as exc: + raise TokenInvalid( + "malformed token payload: invalid timestamp in field 'expires_at'" + ) from exc + + constraints = data.get("constraints", {}) + if not isinstance(constraints, dict): + raise TokenInvalid("malformed token payload: field 'constraints' must be an object") + + key_id = data.get("key_id", "") + if not isinstance(key_id, str): + raise TokenInvalid("malformed token payload: field 'key_id' must be a string") + + return CapabilityToken( + token_id=token_id, + capability_id=capability_id, + principal_id=principal_id, + issued_at=issued_at, + expires_at=expires_at, + constraints=constraints, + audit_id=data.get("audit_id", ""), + signature=data.get("signature", ""), + key_id=key_id, + ) + + +def _log_verify_failure(token_id: str, reason: str, **extra: Any) -> None: + """Log a token verification failure at WARNING.""" + logger.warning("token_verify_failed", extra={"token_id": token_id, "reason": reason, **extra}) + + +def _sign(payload: str, *, key_ring: KeyRing, key_id: str) -> str: + return hmac.new(key_ring.secret_for(key_id), payload.encode(), hashlib.sha256).hexdigest() + + +def sign_token(token: CapabilityToken, *, key_ring: KeyRing, key_id: str) -> str: + """Return the HMAC-SHA256 signature for *token* under *key_id*.""" + return _sign(token._signable_payload(), key_ring=key_ring, key_id=key_id) + + +def verify_token( + token: CapabilityToken, + *, + key_ring: KeyRing, + revocation: RevocationStoreProtocol, + expected_principal_id: str, + expected_capability_id: str, +) -> None: + """Verify a token's revocation state, expiry, signature, and scope bindings. + + The full :meth:`~weaver_kernel.tokens.TokenProvider.verify` contract, + factored out of :class:`~weaver_kernel.tokens.HMACTokenProvider` to keep + that module within its line budget (AGENTS.md). + + Raises: + TokenRevoked: If the token has been revoked. + TokenExpired: If ``token.expires_at`` is in the past. + TokenInvalid: If the token's ``key_id`` is unknown to *key_ring*, or + the HMAC signature does not verify. + TokenScopeError: If principal or capability do not match. + """ + # 0. Revocation (fast lookup before any crypto) + if revocation.is_revoked(token.token_id): + _log_verify_failure(token.token_id, "revoked") + raise TokenRevoked(f"Token '{token.token_id}' has been revoked.") + + # 1. Expiry + now = datetime.datetime.now(tz=datetime.timezone.utc) + if token.expires_at <= now: + _log_verify_failure(token.token_id, "expired", expires_at=token.expires_at.isoformat()) + raise TokenExpired(f"Token '{token.token_id}' expired at {token.expires_at.isoformat()}.") + + # 2. Signature (also resolves the signing key by the token's declared + # key_id — an id outside the configured KeyRing fails closed here as + # TokenInvalid rather than falling back to another secret). + try: + expected_sig = sign_token(token, key_ring=key_ring, key_id=token.key_id) + except TokenInvalid: + _log_verify_failure(token.token_id, "unknown_key_id", key_id=token.key_id) + raise + if not hmac.compare_digest(expected_sig, token.signature): + _log_verify_failure(token.token_id, "invalid_signature") + raise TokenInvalid( + f"Token '{token.token_id}' has an invalid signature. " + "The token may have been tampered with." + ) + if not key_ring.is_active(token.key_id): + # Never logs the secret itself — only the (non-secret) key id — so + # operators can tell when an overlap-window key is still in use and + # it's safe to retire (#185). + logger.info( + "token_verified_non_active_key", + extra={"token_id": token.token_id, "key_id": token.key_id}, + ) + + # 3. Principal binding (confused-deputy prevention) + if token.principal_id != expected_principal_id: + _log_verify_failure(token.token_id, "principal_mismatch") + raise TokenScopeError( + f"Token '{token.token_id}' was issued for principal " + f"'{token.principal_id}', not '{expected_principal_id}'." + ) + + # 4. Capability binding + if token.capability_id != expected_capability_id: + _log_verify_failure(token.token_id, "capability_mismatch") + raise TokenScopeError( + f"Token '{token.token_id}' was issued for capability " + f"'{token.capability_id}', not '{expected_capability_id}'." + ) + + +__all__ = ["KeyRing", "parse_token_dict", "sign_token", "verify_token"] diff --git a/src/weaver_kernel/errors.py b/src/weaver_kernel/errors.py index 245299e..75a939f 100644 --- a/src/weaver_kernel/errors.py +++ b/src/weaver_kernel/errors.py @@ -17,7 +17,15 @@ class TokenInvalid(AgentKernelError): class TokenScopeError(AgentKernelError): - """Raised when a token is used by the wrong principal or for the wrong capability.""" + """Raised when a token is used outside the scope it was signed for. + + Covers a wrong principal or capability, and an invocation whose arguments + violate a signed argument-level constraint (``constraints["args"]``). + """ + + def __init__(self, message: str, *, reason_code: str | None = None) -> None: + super().__init__(message) + self.reason_code: str | None = reason_code class TokenRevoked(AgentKernelError): @@ -52,6 +60,22 @@ class PolicyConfigError(AgentKernelError): """Raised when a declarative policy file is malformed or unreadable.""" +class RateLimitExceeded(AgentKernelError): + """Raised when an invoke-time rate or use limit rejects an invocation. + + Distinct from :class:`PolicyDenied` (a grant-time refusal): this fires + from :meth:`~weaver_kernel.Kernel.invoke` itself, before a driver runs, + when the kernel has opt-in per-invocation limiting configured. Carries a + stable ``reason_code`` (typically + :attr:`~weaver_kernel.policy_reasons.DenialReason.INVOKE_RATE_LIMITED`) + so callers can branch without matching the message. + """ + + def __init__(self, message: str, *, reason_code: str | None = None) -> None: + super().__init__(message) + self.reason_code: str | None = reason_code + + # ── Driver errors ───────────────────────────────────────────────────────────── diff --git a/src/weaver_kernel/kernel/__init__.py b/src/weaver_kernel/kernel/__init__.py index 2a20899..cd09d86 100644 --- a/src/weaver_kernel/kernel/__init__.py +++ b/src/weaver_kernel/kernel/__init__.py @@ -15,7 +15,8 @@ from typing import Any, Literal, overload from ..drivers.base import Driver, StreamingDriver -from ..errors import AgentKernelError, PolicyDenied +from ..enums import SafetyClass +from ..errors import AgentKernelError from ..federation import TrustPolicy from ..firewall.budget_manager import BudgetManager from ..firewall.transform import Firewall @@ -35,6 +36,7 @@ RoutePlan, ) from ..policy import DefaultPolicyEngine, PolicyEngine +from ..rate_limit import RateLimiter from ..registry import CapabilityRegistry from ..router import Router, StaticRouter from ..stats import KernelStats, StatsSnapshot @@ -42,13 +44,14 @@ from ..tokens import CapabilityToken, HMACTokenProvider, TokenProvider from ..trace import TraceStore from ..trace_query import TraceQuery -from ._audit import record_denial_trace, record_expansion_trace +from ._audit import record_expansion_trace from ._dry_run import build_dry_run_result from ._federation import ( perform_advertise, perform_discover_peers, perform_import_remote, ) +from ._grant import perform_grant from ._invoke import perform_invoke from ._stream import invoke_stream_impl @@ -86,7 +89,34 @@ def __init__( trace_store: TraceStoreProtocol | None = None, budget_manager: BudgetManager | None = None, kernel_id: str = "agent-kernel", + invoke_rate_limits: dict[SafetyClass, tuple[int, float]] | None = None, + invoke_rate_limiter: RateLimiter | None = None, ) -> None: + """Construct a kernel. + + Args: + registry: The capability registry to serve requests from. + policy: Grant-time authorization engine. Defaults to + :class:`~weaver_kernel.DefaultPolicyEngine`. + token_provider: Issues and verifies capability tokens. Defaults + to :class:`~weaver_kernel.HMACTokenProvider`. + router: Resolves a capability id to a driver route plan. + firewall: Transforms driver output into a bounded :class:`Frame`. + handle_store: Backing store for paginated result handles. + trace_store: Backing store for the audit trail. + budget_manager: Optional cross-invocation context-budget tracker. + kernel_id: Stable identifier this kernel advertises as. + invoke_rate_limits: Opt-in per-invocation sliding-window rate + limits by :class:`~weaver_kernel.SafetyClass` (#170). + ``None`` (the default) disables invoke-time limiting entirely + — only :meth:`grant_capability`'s grant-time limit applies, + preserving today's behavior. A safety class absent from this + mapping is also left unlimited at invoke time. + invoke_rate_limiter: The :class:`~weaver_kernel.RateLimiter` + backing *invoke_rate_limits*. Defaults to a fresh limiter; + pass one explicitly to inject a clock in tests or to share + state with another limiter. + """ self._registry = registry self._policy: PolicyEngine = policy or DefaultPolicyEngine() self._token_provider: TokenProvider = token_provider or HMACTokenProvider() @@ -98,6 +128,8 @@ def __init__( self._drivers: dict[str, Driver] = {} self._kernel_id = kernel_id self._stats = KernelStats() + self._invoke_limits = invoke_rate_limits + self._invoke_limiter = invoke_rate_limiter or RateLimiter() @property def kernel_id(self) -> str: @@ -137,6 +169,7 @@ def grant_capability( principal: Principal, *, justification: str, + ttl_s: float | None = None, ) -> CapabilityGrant: """Evaluate the policy and, if approved, issue a signed token. @@ -146,61 +179,17 @@ def grant_capability( "who was refused what, and why" (#175). A trace-store write failure is logged but never masks the denial. Denials are also counted in :attr:`stats`. + + Args: + request: The capability request to grant. + principal: The principal requesting the grant. + justification: Free-text justification for the grant. + ttl_s: Requested token lifetime in seconds (#203). ``None`` (the + default) uses the token provider's own default — unchanged + behavior. The policy engine may deny (never clamp) an + excessive value via an optional ``resolve_ttl`` method. """ - capability = self._registry.get(request.capability_id) - try: - decision = self._policy.evaluate( - request, capability, principal, justification=justification - ) - except PolicyDenied as exc: - self._stats.on_denial(exc.reason_code) - # The denial is authoritative and already fails closed (no token is - # issued). Recording its audit trace is best-effort: a trace-store - # write failure must never mask the PolicyDenied the caller expects. - try: - record_denial_trace( - capability_id=request.capability_id, - principal_id=principal.principal_id, - reason_code=exc.reason_code, - message=str(exc), - trace_store=self._trace_store, - ) - except Exception: - logger.warning( - "deny_trace_record_failed", - extra={ - "capability_id": request.capability_id, - "principal_id": principal.principal_id, - "reason_code": exc.reason_code, - }, - exc_info=True, - ) - raise - audit_id = str(uuid.uuid4()) - token = self._token_provider.issue( - capability.capability_id, - principal.principal_id, - constraints=decision.constraints, - audit_id=audit_id, - ) - logger.info( - "grant_capability", - extra={ - "principal_id": principal.principal_id, - "capability_id": capability.capability_id, - "safety_class": capability.safety_class.value, - "audit_id": audit_id, - "token_id": token.token_id, - }, - ) - self._stats.on_grant() - return CapabilityGrant( - request=request, - principal=principal, - decision=decision, - token=token, - audit_id=audit_id, - ) + return perform_grant(self, request, principal, justification=justification, ttl_s=ttl_s) def get_token( self, diff --git a/src/weaver_kernel/kernel/_arg_constraints.py b/src/weaver_kernel/kernel/_arg_constraints.py new file mode 100644 index 0000000..19b8373 --- /dev/null +++ b/src/weaver_kernel/kernel/_arg_constraints.py @@ -0,0 +1,85 @@ +"""Signed argument-level constraint enforcement (#183). + +A :class:`~weaver_kernel.tokens.CapabilityToken` authorises a *capability*, +but by default any arguments. ``constraints["args"]`` lets a grant pin down +*which* arguments an invocation may use — a deterministic, tiny vocabulary +(never a general expression language, per +``docs/agent-context/invariants.md``'s determinism rule): + +- ``allowed_keys``: a list of argument names; any other key is rejected. +- ``pinned``: a ``{key: value}`` map; a *present* key must equal the pinned + value exactly. +- ``prefix``: a ``{key: prefix}`` map; a *present* key's value must be a + string starting with the given prefix. + +All three operate on **top-level keys only** (v1 scope, documented in #183) +and are enforced identically by both :func:`weaver_kernel.Kernel.invoke` and +dry-run, so a dry-run's predicted outcome always matches the real one. + +A key absent from ``args`` never violates ``pinned``/``prefix`` — those +constrain a value the caller *chose to pass*, not argument presence (a +missing required argument is the capability/driver's own concern). This +choice is deliberate and documented per #183's "decide, document it" ask. +""" + +from __future__ import annotations + +from typing import Any + +from ..errors import TokenScopeError +from ..policy_reasons import DenialReason + + +def validate_arg_constraints(constraints: dict[str, Any], args: dict[str, Any]) -> None: + """Enforce a token's signed ``constraints["args"]`` rules against *args*. + + A no-op when the token carries no ``"args"`` constraint. + + Args: + constraints: The verified token's ``constraints`` dict. + args: The invocation's driver arguments. + + Raises: + TokenScopeError: If *args* violates ``allowed_keys``, ``pinned``, or + ``prefix``. Carries + :attr:`~weaver_kernel.policy_reasons.DenialReason.ARG_CONSTRAINT_VIOLATION` + as ``reason_code``. + """ + rules = constraints.get("args") + if not rules: + return + + allowed_keys = rules.get("allowed_keys") + if allowed_keys is not None: + extra = sorted(set(args) - set(allowed_keys)) + if extra: + raise TokenScopeError( + f"Argument(s) {extra} are not in the token's allowed_keys constraint.", + reason_code=str(DenialReason.ARG_CONSTRAINT_VIOLATION), + ) + + pinned: dict[str, Any] | None = rules.get("pinned") + if pinned: + for key, expected in pinned.items(): + if key in args and args[key] != expected: + raise TokenScopeError( + f"Argument {key!r} must equal {expected!r} per the token's " + f"pinned constraint, got {args[key]!r}.", + reason_code=str(DenialReason.ARG_CONSTRAINT_VIOLATION), + ) + + prefix: dict[str, str] | None = rules.get("prefix") + if prefix: + for key, expected_prefix in prefix.items(): + if key not in args: + continue + value = args[key] + if not isinstance(value, str) or not value.startswith(expected_prefix): + raise TokenScopeError( + f"Argument {key!r} must be a string starting with " + f"{expected_prefix!r} per the token's prefix constraint.", + reason_code=str(DenialReason.ARG_CONSTRAINT_VIOLATION), + ) + + +__all__ = ["validate_arg_constraints"] diff --git a/src/weaver_kernel/kernel/_audit.py b/src/weaver_kernel/kernel/_audit.py index c1932c8..7f19106 100644 --- a/src/weaver_kernel/kernel/_audit.py +++ b/src/weaver_kernel/kernel/_audit.py @@ -14,9 +14,10 @@ failures (principal/constraint violations raised by ``HandleStore.expand``) still surface as exceptions and logs, not ``"deny"`` traces. -The redaction helpers are shared with :mod:`._invoke` so an expansion's query -arguments and a denial's message pass through the same firewall scrub used for -invocation traces — the audit store never becomes a sensitive-data sink. +The redaction helpers are shared with :mod:`._trace_record` so an expansion's +query arguments and a denial's message pass through the same firewall scrub +used for invocation traces — the audit store never becomes a sensitive-data +sink. """ from __future__ import annotations @@ -26,7 +27,7 @@ from ..models import ActionTrace, Frame from ..stores import TraceStoreProtocol -from ._invoke import _frame_result_summary, _redact_args_for_trace, _redact_trace_text +from ._trace_record import _frame_result_summary, _redact_args_for_trace, _redact_trace_text def _now() -> datetime.datetime: diff --git a/src/weaver_kernel/kernel/_dry_run.py b/src/weaver_kernel/kernel/_dry_run.py index acf4e87..338c7e4 100644 --- a/src/weaver_kernel/kernel/_dry_run.py +++ b/src/weaver_kernel/kernel/_dry_run.py @@ -26,6 +26,7 @@ ) from ..policy_reasons import AllowReason from ..tokens import CapabilityToken +from ._arg_constraints import validate_arg_constraints _COST_MAP: dict[SafetyClass, Literal["low", "medium", "high"]] = { SafetyClass.READ: "low", @@ -50,7 +51,15 @@ def build_dry_run_result( uses: admin gate for ``raw`` first, then budget escalation if a :class:`BudgetManager` is attached. Operation resolution mirrors the drivers' own ``args.get("operation", capability_id)`` convention. + + Raises: + TokenScopeError: If *args* violates the token's signed + ``constraints["args"]`` — the same check :func:`perform_invoke` + makes, so a dry-run's predicted outcome always matches invoke's + actual one (#183). Invoke-time rate limiting is *not* checked + here: a dry-run must never consume that limit. """ + validate_arg_constraints(token.constraints, args) driver_id = plan.driver_ids[0] if plan.driver_ids else "" operation = str(args.get("operation", token.capability_id)) diff --git a/src/weaver_kernel/kernel/_grant.py b/src/weaver_kernel/kernel/_grant.py new file mode 100644 index 0000000..dcb9d4e --- /dev/null +++ b/src/weaver_kernel/kernel/_grant.py @@ -0,0 +1,140 @@ +"""Internal helper for :meth:`Kernel.grant_capability`. + +Split out of :mod:`kernel` to keep the public API module ≤ 300 lines +(AGENTS.md), the same way :mod:`._invoke` and :mod:`._dry_run` were. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import TYPE_CHECKING, Any + +from ..errors import PolicyDenied +from ..models import CapabilityGrant +from ._audit import record_denial_trace + +if TYPE_CHECKING: # pragma: no cover + from ..models import Capability, CapabilityRequest, Principal + from ..policy import PolicyEngine + from . import Kernel + +logger = logging.getLogger("weaver_kernel.kernel") + + +def _resolve_ttl( + policy: PolicyEngine, capability: Capability, principal: Principal, ttl_s: float | None +) -> float | None: + """Resolve the effective grant TTL via the policy's optional ``resolve_ttl``. + + ``resolve_ttl(capability, principal, ttl_s) -> float | None`` is + deliberately *not* part of the :class:`~weaver_kernel.PolicyEngine` + Protocol (adding a required method there would break every third-party + implementation, the same reasoning that split + :class:`~weaver_kernel.ExplainingPolicyEngine` off in 0.7.0). Engines + that don't implement it simply pass *ttl_s* through unchanged — TTL + validation is opt-in policy behavior, not a kernel-mandated one (#203). + + Raises: + PolicyDenied: If the policy engine's ``resolve_ttl`` rejects *ttl_s* + (e.g. it is non-positive, or exceeds a configured + per-safety-class maximum). + """ + resolve_ttl = getattr(policy, "resolve_ttl", None) + if resolve_ttl is None: + return ttl_s + result: float | None = resolve_ttl(capability, principal, ttl_s) + return result + + +def perform_grant( + kernel: Kernel, + request: CapabilityRequest, + principal: Principal, + *, + justification: str, + ttl_s: float | None = None, +) -> CapabilityGrant: + """Evaluate the policy and, if approved, issue a signed token. + + On a :class:`~weaver_kernel.PolicyDenied` rejection — from either the + policy engine's ``evaluate()`` or an over-maximum *ttl_s* — a ``"deny"`` + audit record (carrying the stable reason code) is written to the trace + store (best-effort) before the exception propagates, so the audit trail + answers "who was refused what, and why" (#175). A trace-store write + failure is logged but never masks the denial. Denials are also counted + in :attr:`~weaver_kernel.Kernel.stats`. + + Args: + kernel: The orchestrating :class:`~weaver_kernel.Kernel`. + request: The capability request to grant. + principal: The principal requesting the grant. + justification: Free-text justification for the grant. + ttl_s: Requested token lifetime in seconds. ``None`` (the default) + uses the token provider's own default — unchanged behavior. A + policy engine may deny (never silently clamp) an excessive value + via an optional ``resolve_ttl`` method (#203). + + Returns: + A :class:`~weaver_kernel.CapabilityGrant` carrying the signed token. + """ + capability = kernel._registry.get(request.capability_id) + try: + decision = kernel._policy.evaluate( + request, capability, principal, justification=justification + ) + effective_ttl_s = _resolve_ttl(kernel._policy, capability, principal, ttl_s) + except PolicyDenied as exc: + kernel._stats.on_denial(exc.reason_code) + # The denial is authoritative and already fails closed (no token is + # issued). Recording its audit trace is best-effort: a trace-store + # write failure must never mask the PolicyDenied the caller expects. + try: + record_denial_trace( + capability_id=request.capability_id, + principal_id=principal.principal_id, + reason_code=exc.reason_code, + message=str(exc), + trace_store=kernel._trace_store, + ) + except Exception: + logger.warning( + "deny_trace_record_failed", + extra={ + "capability_id": request.capability_id, + "principal_id": principal.principal_id, + "reason_code": exc.reason_code, + }, + exc_info=True, + ) + raise + audit_id = str(uuid.uuid4()) + issue_kwargs: dict[str, Any] = {"constraints": decision.constraints, "audit_id": audit_id} + if effective_ttl_s is not None: + issue_kwargs["ttl_seconds"] = effective_ttl_s + token = kernel._token_provider.issue( + capability.capability_id, + principal.principal_id, + **issue_kwargs, + ) + logger.info( + "grant_capability", + extra={ + "principal_id": principal.principal_id, + "capability_id": capability.capability_id, + "safety_class": capability.safety_class.value, + "audit_id": audit_id, + "token_id": token.token_id, + }, + ) + kernel._stats.on_grant() + return CapabilityGrant( + request=request, + principal=principal, + decision=decision, + token=token, + audit_id=audit_id, + ) + + +__all__ = ["perform_grant"] diff --git a/src/weaver_kernel/kernel/_invoke.py b/src/weaver_kernel/kernel/_invoke.py index 81118fc..82909fe 100644 --- a/src/weaver_kernel/kernel/_invoke.py +++ b/src/weaver_kernel/kernel/_invoke.py @@ -16,19 +16,16 @@ from __future__ import annotations import asyncio -import datetime import logging import uuid from dataclasses import replace -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from ..drivers.base import ExecutionContext -from ..enums import SensitivityTag -from ..errors import DriverError +from ..enums import SafetyClass +from ..errors import DriverError, RateLimitExceeded, TokenScopeError from ..firewall.budget_manager import BudgetManager -from ..firewall.redaction import redact from ..models import ( - ActionTrace, Capability, Frame, Handle, @@ -36,76 +33,17 @@ ResponseMode, RoutePlan, ) -from ..stores import TraceStoreProtocol +from ..policy_reasons import DenialReason from ..tokens import CapabilityToken +from ._arg_constraints import validate_arg_constraints from ._driver_exec import execute_with_fallback, resolve_invoke_timeout +from ._trace_record import _frame_result_summary, record_failure_trace, record_success_trace if TYPE_CHECKING: # pragma: no cover from . import Kernel logger = logging.getLogger("weaver_kernel.kernel") -_MEMORY_CAPABILITY_PREFIX = "memory." -_MEMORY_SENSITIVE_ARG_KEYS: frozenset[str] = frozenset( - {"payload", "content", "value", "memory", "text", "body"} -) - - -def _redact_args_for_trace(capability_id: str, args: dict[str, Any]) -> dict[str, Any]: - """Redact sensitive values from :class:`ActionTrace.args` before storage. - - The trace store is the long-lived audit record; if invocation arguments - carry user content, secrets passed as parameters, or PII, storing them raw - makes the store itself a sensitive-data sink — undermining the I-01 - boundary the :class:`Firewall` enforces on *outputs*. Two layers apply: - - 1. **Memory payload stripping.** Memory capabilities (``capability_id`` - starting with ``"memory."``) carry durable free text under known keys - (``payload``, ``content``, …); those values are replaced wholesale with - ``"[REDACTED]"`` (keys preserved so audit can confirm a payload was - provided). - 2. **General pattern/field redaction for every capability.** All args are - then passed through the same :func:`~weaver_kernel.firewall.redaction.redact` - used on driver output, so inline secrets/PII and sensitive field names - are scrubbed regardless of the capability namespace (#172). - """ - if capability_id.startswith(_MEMORY_CAPABILITY_PREFIX): - args = { - k: ("[REDACTED]" if k.lower() in _MEMORY_SENSITIVE_ARG_KEYS else v) - for k, v in args.items() - } - redacted, _ = redact(args) - return cast(dict[str, Any], redacted) - - -def _redact_trace_text(text: str) -> str: - """Scrub inline secrets/PII from free text before it enters a trace. - - ``DriverError`` messages can embed raw response bodies (e.g. up to 200 - characters of an HTTP error body), so error text recorded on an - :class:`ActionTrace` is run through the firewall's string redactor first. - """ - redacted, _ = redact(text) - return cast(str, redacted) - - -def _frame_result_summary(frame: Frame) -> dict[str, Any]: - """Build a redaction-safe result summary from a *firewalled* Frame. - - Records only counts and flags taken from the already-transformed Frame — - never raw driver data — so it preserves the I-01 boundary the Firewall - enforces and keeps sensitive payloads out of the audit trail. Stored on - :attr:`~weaver_kernel.models.ActionTrace.result_summary` so an invocation's - outcome (e.g. a safety check's pass/block decision) is auditable via - :meth:`~weaver_kernel.Kernel.explain`. - """ - return { - "fact_count": len(frame.facts), - "row_count": len(frame.table_preview), - "warning_count": len(frame.warnings), - "has_handle": frame.handle is not None, - } - def resolve_effective_mode( *, @@ -131,72 +69,87 @@ def resolve_effective_mode( return effective -def record_failure_trace( - *, - action_id: str, - capability_id: str, - principal_id: str, - token_id: str, - args: dict[str, Any], - response_mode: ResponseMode, - error_message: str, - trace_store: TraceStoreProtocol, - sensitivity: SensitivityTag = SensitivityTag.NONE, - driver_id: str = "", +def _check_invoke_rate_limit( + kernel: Kernel, *, principal_id: str, capability_id: str, safety_class: SafetyClass ) -> None: - """Persist an :class:`ActionTrace` for a failed run. + """Enforce the opt-in per-invocation rate limit, if configured (#170). - Args: - driver_id: The driver implicated in the failure — the one that ran and - then failed downstream, or the last one attempted when every driver - failed. Empty only when no driver was reached. + A no-op unless the kernel was constructed with ``invoke_rate_limits``. + Checks and records against the same limiter *synchronously* (no + ``await`` in between) so two concurrent ``invoke()`` calls on the same + principal+capability cannot both pass the check before either records — + see the concurrency caveat in ``docs/security.md``. + + Raises: + RateLimitExceeded: If the sliding-window limit for *safety_class* + would be exceeded by this invocation. """ - trace_store.record( - ActionTrace( - action_id=action_id, - capability_id=capability_id, - principal_id=principal_id, - token_id=token_id, - invoked_at=datetime.datetime.now(tz=datetime.timezone.utc), - args=_redact_args_for_trace(capability_id, args), - response_mode=response_mode, - driver_id=driver_id, - sensitivity=sensitivity, - error=_redact_trace_text(error_message), + limits = kernel._invoke_limits + if limits is None: + return + limit_window = limits.get(safety_class) + if limit_window is None: + return + limit, window_seconds = limit_window + key = f"{principal_id}:{capability_id}" + limiter = kernel._invoke_limiter + if not limiter.check(key, limit, window_seconds): + raise RateLimitExceeded( + f"Per-invocation rate limit exceeded for '{principal_id}' on " + f"'{capability_id}' ({limit} per {window_seconds}s).", + reason_code=str(DenialReason.INVOKE_RATE_LIMITED), ) - ) + limiter.record(key) -def record_success_trace( +def _enforce_pre_execution( + kernel: Kernel, *, - action_id: str, - capability_id: str, - principal_id: str, - token_id: str, + token: CapabilityToken, args: dict[str, Any], + principal: Principal, + capability: Capability, + action_id: str, + effective_mode: ResponseMode, response_mode: ResponseMode, - driver_id: str, - handle_id: str | None, - result_summary: dict[str, Any] | None, - trace_store: TraceStoreProtocol, - sensitivity: SensitivityTag = SensitivityTag.NONE, ) -> None: - """Persist an :class:`ActionTrace` for a successful invocation.""" - trace_store.record( - ActionTrace( + """Run checks that must pass *before* a driver runs or budget is reserved. + + Argument constraints (#183) and, if configured, the per-invocation rate + limit (#170). A violation records a ``"deny"`` failure trace (I-02) and + re-raises — never a bare exception escaping unaudited. + + Raises: + TokenScopeError: If *args* violates the token's ``constraints["args"]``. + RateLimitExceeded: If invoke-time rate limiting is enabled and the + window limit for this principal+capability is exceeded. + """ + try: + validate_arg_constraints(token.constraints, args) + _check_invoke_rate_limit( + kernel, + principal_id=principal.principal_id, + capability_id=token.capability_id, + safety_class=capability.safety_class, + ) + except (TokenScopeError, RateLimitExceeded) as exc: + record_failure_trace( action_id=action_id, - capability_id=capability_id, - principal_id=principal_id, - token_id=token_id, - invoked_at=datetime.datetime.now(tz=datetime.timezone.utc), - args=_redact_args_for_trace(capability_id, args), - response_mode=response_mode, - driver_id=driver_id, - sensitivity=sensitivity, - handle_id=handle_id, - result_summary=result_summary, + capability_id=token.capability_id, + principal_id=principal.principal_id, + token_id=token.token_id, + args=args, + response_mode=effective_mode, + error_message=str(exc), + trace_store=kernel._traces, + sensitivity=capability.sensitivity, + reason_code=exc.reason_code, + event_type="deny", ) - ) + kernel._stats.on_invocation( + failed=True, fallback=False, redacted=False, downgraded=effective_mode != response_mode + ) + raise async def perform_invoke( @@ -238,6 +191,19 @@ async def perform_invoke( principal=principal, budget_manager=kernel.budget, ) + # #170, #183: argument constraints and (if configured) the per-invocation + # rate limit are enforced before budget reservation, so a violation never + # leaks a reservation and never reaches a driver. + _enforce_pre_execution( + kernel, + token=token, + args=args, + principal=principal, + capability=capability, + action_id=action_id, + effective_mode=effective_mode, + response_mode=response_mode, + ) reserved_tokens: int | None = None if kernel.budget is not None: reserved_tokens = await kernel.budget.allocate() diff --git a/src/weaver_kernel/kernel/_stream.py b/src/weaver_kernel/kernel/_stream.py index 072dab1..cfbede5 100644 --- a/src/weaver_kernel/kernel/_stream.py +++ b/src/weaver_kernel/kernel/_stream.py @@ -36,12 +36,8 @@ ) from ..tokens import CapabilityToken from ._driver_exec import resolve_invoke_timeout -from ._invoke import ( - _frame_result_summary, - _redact_args_for_trace, - _redact_trace_text, - resolve_effective_mode, -) +from ._invoke import resolve_effective_mode +from ._trace_record import _frame_result_summary, _redact_args_for_trace, _redact_trace_text if TYPE_CHECKING: # pragma: no cover from . import Kernel diff --git a/src/weaver_kernel/kernel/_trace_record.py b/src/weaver_kernel/kernel/_trace_record.py new file mode 100644 index 0000000..ff05e53 --- /dev/null +++ b/src/weaver_kernel/kernel/_trace_record.py @@ -0,0 +1,163 @@ +"""Audit-trace builders for the invoke pipeline. + +Split out of :mod:`._invoke` to keep it within its ratchet ceiling +(AGENTS.md). These helpers are shared with :mod:`._stream` (streaming +invocations) and :mod:`._audit` (denial/expansion events) so every audit +record in the kernel redacts sensitive data through the same path. +""" + +from __future__ import annotations + +import datetime +from typing import Any, cast + +from ..enums import SensitivityTag +from ..firewall.redaction import redact +from ..models import ActionTrace, Frame, ResponseMode, TraceEventType +from ..stores import TraceStoreProtocol + +_MEMORY_CAPABILITY_PREFIX = "memory." +_MEMORY_SENSITIVE_ARG_KEYS: frozenset[str] = frozenset( + {"payload", "content", "value", "memory", "text", "body"} +) + + +def _redact_args_for_trace(capability_id: str, args: dict[str, Any]) -> dict[str, Any]: + """Redact sensitive values from :class:`ActionTrace.args` before storage. + + The trace store is the long-lived audit record; if invocation arguments + carry user content, secrets passed as parameters, or PII, storing them raw + makes the store itself a sensitive-data sink — undermining the I-01 + boundary the :class:`Firewall` enforces on *outputs*. Two layers apply: + + 1. **Memory payload stripping.** Memory capabilities (``capability_id`` + starting with ``"memory."``) carry durable free text under known keys + (``payload``, ``content``, …); those values are replaced wholesale with + ``"[REDACTED]"`` (keys preserved so audit can confirm a payload was + provided). + 2. **General pattern/field redaction for every capability.** All args are + then passed through the same :func:`~weaver_kernel.firewall.redaction.redact` + used on driver output, so inline secrets/PII and sensitive field names + are scrubbed regardless of the capability namespace (#172). + """ + if capability_id.startswith(_MEMORY_CAPABILITY_PREFIX): + args = { + k: ("[REDACTED]" if k.lower() in _MEMORY_SENSITIVE_ARG_KEYS else v) + for k, v in args.items() + } + redacted, _ = redact(args) + return cast(dict[str, Any], redacted) + + +def _redact_trace_text(text: str) -> str: + """Scrub inline secrets/PII from free text before it enters a trace. + + ``DriverError`` messages can embed raw response bodies (e.g. up to 200 + characters of an HTTP error body), so error text recorded on an + :class:`ActionTrace` is run through the firewall's string redactor first. + """ + redacted, _ = redact(text) + return cast(str, redacted) + + +def _frame_result_summary(frame: Frame) -> dict[str, Any]: + """Build a redaction-safe result summary from a *firewalled* Frame. + + Records only counts and flags taken from the already-transformed Frame — + never raw driver data — so it preserves the I-01 boundary the Firewall + enforces and keeps sensitive payloads out of the audit trail. Stored on + :attr:`~weaver_kernel.models.ActionTrace.result_summary` so an invocation's + outcome (e.g. a safety check's pass/block decision) is auditable via + :meth:`~weaver_kernel.Kernel.explain`. + """ + return { + "fact_count": len(frame.facts), + "row_count": len(frame.table_preview), + "warning_count": len(frame.warnings), + "has_handle": frame.handle is not None, + } + + +def record_failure_trace( + *, + action_id: str, + capability_id: str, + principal_id: str, + token_id: str, + args: dict[str, Any], + response_mode: ResponseMode, + error_message: str, + trace_store: TraceStoreProtocol, + sensitivity: SensitivityTag = SensitivityTag.NONE, + driver_id: str = "", + reason_code: str | None = None, + event_type: TraceEventType = "invoke", +) -> None: + """Persist an :class:`ActionTrace` for a failed run. + + Args: + driver_id: The driver implicated in the failure — the one that ran and + then failed downstream, or the last one attempted when every driver + failed. Empty when no driver was reached (e.g. a pre-execution + constraint or rate-limit denial). + reason_code: Stable machine-readable code for the failure, when one + applies (e.g. an invoke-time policy denial). ``None`` for + unclassified driver failures. + event_type: ``"invoke"`` for a driver-execution failure (default); + ``"deny"`` for an invoke-time policy refusal (argument-constraint + violation, per-invocation rate limit) that never reached a driver. + """ + trace_store.record( + ActionTrace( + action_id=action_id, + capability_id=capability_id, + principal_id=principal_id, + token_id=token_id, + invoked_at=datetime.datetime.now(tz=datetime.timezone.utc), + args=_redact_args_for_trace(capability_id, args), + response_mode=response_mode, + driver_id=driver_id, + sensitivity=sensitivity, + error=_redact_trace_text(error_message), + reason_code=reason_code, + event_type=event_type, + ) + ) + + +def record_success_trace( + *, + action_id: str, + capability_id: str, + principal_id: str, + token_id: str, + args: dict[str, Any], + response_mode: ResponseMode, + driver_id: str, + handle_id: str | None, + result_summary: dict[str, Any] | None, + trace_store: TraceStoreProtocol, + sensitivity: SensitivityTag = SensitivityTag.NONE, +) -> None: + """Persist an :class:`ActionTrace` for a successful invocation.""" + trace_store.record( + ActionTrace( + action_id=action_id, + capability_id=capability_id, + principal_id=principal_id, + token_id=token_id, + invoked_at=datetime.datetime.now(tz=datetime.timezone.utc), + args=_redact_args_for_trace(capability_id, args), + response_mode=response_mode, + driver_id=driver_id, + sensitivity=sensitivity, + handle_id=handle_id, + result_summary=result_summary, + ) + ) + + +__all__ = [ + "record_failure_trace", + "record_success_trace", +] diff --git a/src/weaver_kernel/otel.py b/src/weaver_kernel/otel.py index e21a688..65e7724 100644 --- a/src/weaver_kernel/otel.py +++ b/src/weaver_kernel/otel.py @@ -204,6 +204,7 @@ def instrumented_grant( principal: Any, *, justification: str, + ttl_s: float | None = None, ) -> Any: attributes: dict[str, Any] = { ATTR_PRINCIPAL: principal.principal_id, @@ -211,7 +212,7 @@ def instrumented_grant( } with tracer.start_as_current_span("weaver_kernel.grant", attributes=attributes) as span: try: - return original_grant(request, principal, justification=justification) + return original_grant(request, principal, justification=justification, ttl_s=ttl_s) except Exception as exc: reason_code = getattr(exc, "reason_code", "") or "" denials.add( diff --git a/src/weaver_kernel/policy.py b/src/weaver_kernel/policy.py index 2d7dde3..2f4eba9 100644 --- a/src/weaver_kernel/policy.py +++ b/src/weaver_kernel/policy.py @@ -19,6 +19,7 @@ Principal, ) from .policy_reasons import AllowReason, DenialReason +from .policy_ttl import check_ttl from .rate_limit import DEFAULT_RATE_LIMITS, SERVICE_RATE_MULTIPLIER, RateLimiter logger = logging.getLogger(__name__) @@ -133,6 +134,7 @@ def __init__( *, rate_limits: dict[SafetyClass, tuple[int, float]] | None = None, clock: Callable[[], float] | None = None, + max_ttl_s: dict[SafetyClass, float] | None = None, ) -> None: """Initialise the policy engine. @@ -143,6 +145,9 @@ def __init__( unspecified safety classes retain their default limits. clock: Monotonic clock callable for rate-limiter. Defaults to :func:`time.monotonic`. + max_ttl_s: Per-safety-class ceiling on a requested grant TTL + (#203), enforced by :meth:`resolve_ttl`. A safety class + absent from this mapping has no maximum. """ limits = dict(_DEFAULT_RATE_LIMITS) if rate_limits is not None: @@ -156,6 +161,7 @@ def __init__( ) self._rate_limits = limits self._limiter = RateLimiter(clock=clock) + self._max_ttl_s = max_ttl_s @staticmethod def _deny( @@ -177,6 +183,47 @@ def _deny( ) return PolicyDenied(reason, reason_code=reason_code) + def resolve_ttl( + self, + capability: Capability, + principal: Principal, + ttl_s: float | None, + ) -> float | None: + """Validate a requested grant TTL against the configured maximum (#203). + + Not part of the :class:`PolicyEngine` protocol — an optional, richer + capability that :meth:`~weaver_kernel.Kernel.grant_capability` looks + up via ``getattr`` before issuing a token, the same non-breaking + pattern used for :class:`ExplainingPolicyEngine`. A policy engine + that doesn't implement it simply leaves *ttl_s* unvalidated. + + Args: + capability: The capability being granted; its ``safety_class`` + selects the applicable entry in :attr:`max_ttl_s`. + principal: The requesting principal (used only for the denial + log/audit context). + ttl_s: The caller-requested TTL in seconds, or ``None`` for the + token provider's own default. + + Returns: + *ttl_s* unchanged — never clamped. ``None`` in, ``None`` out. + + Raises: + PolicyDenied: If *ttl_s* is not positive, or exceeds the + configured :attr:`max_ttl_s` for ``capability.safety_class``. + """ + violation = check_ttl( + ttl_s, max_ttl_s=self._max_ttl_s, safety_class=capability.safety_class + ) + if violation is not None: + raise self._deny( + violation.message, + principal_id=principal.principal_id, + capability_id=capability.capability_id, + reason_code=violation.reason_code, + ) + return ttl_s + def evaluate( self, request: CapabilityRequest, diff --git a/src/weaver_kernel/policy_reasons.py b/src/weaver_kernel/policy_reasons.py index 8c00f35..0f50863 100644 --- a/src/weaver_kernel/policy_reasons.py +++ b/src/weaver_kernel/policy_reasons.py @@ -97,6 +97,19 @@ class DenialReason(_StrEnumCompat): ``memory_reader_sensitive`` role. """ + # Invoke-time enforcement (#170, #183, #203) + INVOKE_RATE_LIMITED = "invoke_rate_limited" + """The per-invocation sliding-window rate limit was exceeded. Distinct + from :attr:`RATE_LIMITED`, which is the grant-time limit.""" + + ARG_CONSTRAINT_VIOLATION = "arg_constraint_violation" + """An invocation's arguments violated a signed ``constraints["args"]`` + rule (``allowed_keys``, ``pinned``, or ``prefix``).""" + + TTL_EXCEEDS_MAX = "ttl_exceeds_max" + """A requested grant TTL (``ttl_s``) exceeded the policy-configured + maximum for the capability's safety class.""" + class AllowReason(_StrEnumCompat): """Stable codes describing why a policy decision allowed a request.""" diff --git a/src/weaver_kernel/policy_ttl.py b/src/weaver_kernel/policy_ttl.py new file mode 100644 index 0000000..bc43b7e --- /dev/null +++ b/src/weaver_kernel/policy_ttl.py @@ -0,0 +1,61 @@ +"""Grant-TTL validation for :class:`~weaver_kernel.DefaultPolicyEngine` (#203). + +Split out of :mod:`policy` to keep it within its ratchet ceiling (AGENTS.md), +the same way :mod:`rate_limit` was. Pure and stateless: the caller +(:meth:`~weaver_kernel.DefaultPolicyEngine.resolve_ttl`) turns a violation +into a logged, raised :class:`~weaver_kernel.PolicyDenied` — this module only +decides *whether* one occurred. +""" + +from __future__ import annotations + +from typing import NamedTuple + +from .enums import SafetyClass +from .policy_reasons import DenialReason + + +class TtlViolation(NamedTuple): + """Why a requested grant TTL was rejected.""" + + message: str + reason_code: str + + +def check_ttl( + ttl_s: float | None, + *, + max_ttl_s: dict[SafetyClass, float] | None, + safety_class: SafetyClass, +) -> TtlViolation | None: + """Validate a requested grant TTL against a per-safety-class maximum. + + Args: + ttl_s: The caller-requested TTL in seconds, or ``None`` (never a + violation — the token provider's own default applies). + max_ttl_s: Per-safety-class ceiling; ``None`` or a missing entry for + *safety_class* means no maximum is configured. + safety_class: The safety class of the capability being granted. + + Returns: + A :class:`TtlViolation` describing the problem, or ``None`` if + *ttl_s* is acceptable. + """ + if ttl_s is None: + return None + if ttl_s <= 0: + return TtlViolation( + f"Requested TTL {ttl_s}s must be positive.", + str(DenialReason.INVALID_CONSTRAINT), + ) + limit = max_ttl_s.get(safety_class) if max_ttl_s else None + if limit is not None and ttl_s > limit: + return TtlViolation( + f"Requested TTL {ttl_s}s exceeds the {limit}s maximum for " + f"{safety_class.value} capabilities.", + str(DenialReason.TTL_EXCEEDS_MAX), + ) + return None + + +__all__ = ["TtlViolation", "check_ttl"] diff --git a/src/weaver_kernel/tokens.py b/src/weaver_kernel/tokens.py index 8b3a450..81b6995 100644 --- a/src/weaver_kernel/tokens.py +++ b/src/weaver_kernel/tokens.py @@ -3,16 +3,13 @@ from __future__ import annotations import datetime -import hashlib -import hmac import json import logging 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 ._token_signing import KeyRing, parse_token_dict, sign_token, verify_token from .stores import InMemoryRevocationStore, RevocationStoreProtocol logger = logging.getLogger(__name__) @@ -38,6 +35,13 @@ class CapabilityToken: constraints: dict[str, Any] = field(default_factory=dict) audit_id: str = "" signature: str = "" + key_id: str = "" + """Which signing key this token was signed with (#185). + + Opaque to callers; used only by :class:`HMACTokenProvider` to select the + right secret out of its :class:`~weaver_kernel._token_signing.KeyRing` + during verification. ``""`` for a single-key (non-rotating) provider. + """ # ── Serialization ───────────────────────────────────────────────────────── @@ -51,6 +55,7 @@ def _signable_payload(self) -> str: "expires_at": self.expires_at.isoformat(), "constraints": self.constraints, "audit_id": self.audit_id, + "key_id": self.key_id, } return json.dumps(payload, sort_keys=True, separators=(",", ":")) @@ -65,21 +70,18 @@ def to_dict(self) -> dict[str, Any]: "constraints": self.constraints, "audit_id": self.audit_id, "signature": self.signature, + "key_id": self.key_id, } @classmethod def from_dict(cls, data: dict[str, Any]) -> CapabilityToken: - """Reconstruct a token from a plain dict.""" - return cls( - token_id=data["token_id"], - capability_id=data["capability_id"], - principal_id=data["principal_id"], - issued_at=datetime.datetime.fromisoformat(data["issued_at"]), - expires_at=datetime.datetime.fromisoformat(data["expires_at"]), - constraints=data.get("constraints", {}), - audit_id=data.get("audit_id", ""), - signature=data.get("signature", ""), - ) + """Reconstruct a token from a plain dict. + + Raises: + TokenInvalid: If a required field is missing or malformed. See + :func:`~weaver_kernel._token_signing.parse_token_dict`. + """ + return parse_token_dict(data) # ── Protocol ────────────────────────────────────────────────────────────────── @@ -94,7 +96,7 @@ def issue( principal_id: str, *, constraints: dict[str, Any] | None = None, - ttl_seconds: int = 3600, + ttl_seconds: float = 3600, audit_id: str = "", ) -> CapabilityToken: """Issue a new token. @@ -160,9 +162,13 @@ def revoke_all(self, principal_id: str) -> int: class HMACTokenProvider: """Issues and verifies HMAC-SHA256 capability tokens. - The signing secret is read from the ``WEAVER_KERNEL_SECRET`` environment - variable. If the variable is absent a random development secret is - generated and a warning is logged. + By default, the signing secret is read from the ``WEAVER_KERNEL_SECRET`` + environment variable; if absent, a random development secret is generated + and a warning is logged. Pass *secrets* + *active_key_id* to enable + signing-key rotation (#185): tokens carry the id of the key that signed + them, so verification can accept a set of keys — including ones retired + from active issuance — while new tokens are always signed with the + active key. """ def __init__( @@ -170,33 +176,35 @@ def __init__( secret: str | None = None, *, revocation_store: RevocationStoreProtocol | None = None, + secrets: dict[str, str] | None = None, + active_key_id: str | None = None, ) -> None: - self._secret = secret # None → use env / dev fallback at call time + """Construct a provider. + + Args: + secret: A single legacy secret (no rotation). Mutually exclusive + with *secrets* in practice — when *secrets* is given, *secret* + is ignored. ``None`` resolves from the environment. + revocation_store: Where revoked/tracked token state lives. + Defaults to an in-memory store. + secrets: A ``{key_id: secret}`` map for key rotation. When given, + *active_key_id* selects which key new tokens are signed with. + active_key_id: The key id new tokens are signed under. Required + when *secrets* has more than one entry; defaults to the sole + key otherwise. + """ + self._key_ring = KeyRing(secrets, active_key_id=active_key_id, legacy_secret=secret) # 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: - """Log a token verification failure at WARNING.""" - logger.warning( - "token_verify_failed", - extra={"token_id": token_id, "reason": reason, **extra}, - ) - - def _secret_bytes(self) -> bytes: - return (self._secret or _get_secret()).encode() - - def _sign(self, payload: str) -> str: - return hmac.new(self._secret_bytes(), payload.encode(), hashlib.sha256).hexdigest() - def issue( self, capability_id: str, principal_id: str, *, constraints: dict[str, Any] | None = None, - ttl_seconds: int = 3600, + ttl_seconds: float = 3600, audit_id: str = "", ) -> CapabilityToken: """Issue a new signed token. @@ -209,9 +217,11 @@ def issue( audit_id: Audit trail ID to embed in the token. Returns: - A freshly signed :class:`CapabilityToken`. + A freshly signed :class:`CapabilityToken`, signed with the + provider's active signing key. """ now = datetime.datetime.now(tz=datetime.timezone.utc) + key_id = self._key_ring.active_key_id token = CapabilityToken( token_id=str(uuid.uuid4()), capability_id=capability_id, @@ -220,8 +230,9 @@ def issue( expires_at=now + datetime.timedelta(seconds=ttl_seconds), constraints=constraints or {}, audit_id=audit_id, + key_id=key_id, ) - token.signature = self._sign(token._signable_payload()) + token.signature = sign_token(token, key_ring=self._key_ring, key_id=key_id) self._revocation.track(principal_id, token.token_id, token.expires_at) logger.debug( "token_issued", @@ -292,45 +303,14 @@ def verify( Raises: TokenRevoked: If the token has been revoked. TokenExpired: If ``token.expires_at`` is in the past. - TokenInvalid: If the HMAC signature does not verify. + TokenInvalid: If the token's ``key_id`` is unknown, or the HMAC + signature does not verify. TokenScopeError: If principal or capability do not match. """ - # 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.") - - # 1. Expiry - now = datetime.datetime.now(tz=datetime.timezone.utc) - if token.expires_at <= now: - self._log_verify_failure( - token.token_id, "expired", expires_at=token.expires_at.isoformat() - ) - raise TokenExpired( - f"Token '{token.token_id}' expired at {token.expires_at.isoformat()}." - ) - - # 2. Signature - expected_sig = self._sign(token._signable_payload()) - if not hmac.compare_digest(expected_sig, token.signature): - self._log_verify_failure(token.token_id, "invalid_signature") - raise TokenInvalid( - f"Token '{token.token_id}' has an invalid signature. " - "The token may have been tampered with." - ) - - # 3. Principal binding (confused-deputy prevention) - if token.principal_id != expected_principal_id: - self._log_verify_failure(token.token_id, "principal_mismatch") - raise TokenScopeError( - f"Token '{token.token_id}' was issued for principal " - f"'{token.principal_id}', not '{expected_principal_id}'." - ) - - # 4. Capability binding - if token.capability_id != expected_capability_id: - self._log_verify_failure(token.token_id, "capability_mismatch") - raise TokenScopeError( - f"Token '{token.token_id}' was issued for capability " - f"'{token.capability_id}', not '{expected_capability_id}'." - ) + verify_token( + token, + key_ring=self._key_ring, + revocation=self._revocation, + expected_principal_id=expected_principal_id, + expected_capability_id=expected_capability_id, + ) diff --git a/tests/test_architecture.py b/tests/test_architecture.py index afd428a..6aa6742 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -47,10 +47,10 @@ # ceiling (ratchet: these may shrink, never grow past this). Shrinking a module # below 300 should remove it from this map. New modules are not allowed here. _SIZE_RATCHET: dict[str, int] = { - "__init__.py": 341, + "__init__.py": 345, # +4: RateLimitExceeded (#170) and RateLimiter public exports "models.py": 753, - "policy.py": 652, - "kernel/__init__.py": 541, + "policy.py": 699, # +47: DefaultPolicyEngine.resolve_ttl + max_ttl_s (#203) + "kernel/__init__.py": 530, # shrunk: grant_capability extracted to _grant.py (#203) "adapters/_base.py": 459, "kernel/_invoke.py": 390, "firewall/transform.py": 377, diff --git a/tests/test_kernel.py b/tests/test_kernel.py index c163075..b420c79 100644 --- a/tests/test_kernel.py +++ b/tests/test_kernel.py @@ -16,9 +16,13 @@ Kernel, PolicyDenied, Principal, + RateLimiter, + RateLimitExceeded, SafetyClass, StaticRouter, TokenExpired, + TokenInvalid, + TokenScopeError, ) from weaver_kernel.drivers.base import ExecutionContext from weaver_kernel.errors import FirewallError @@ -273,8 +277,15 @@ def transform(self, *args: object, **kwargs: object): # type: ignore[override] raise FirewallError("transform boom") -def _single_driver_kernel(driver: object, *, budget: int | None = None) -> Kernel: - """Build a kernel routing ``cap`` to *driver*, optionally with a budget.""" +def _single_driver_kernel( + driver: object, + *, + budget: int | None = None, + invoke_rate_limits: dict[SafetyClass, tuple[int, float]] | None = None, + invoke_rate_limiter: RateLimiter | None = None, +) -> Kernel: + """Build a kernel routing ``cap`` to *driver*, optionally with a budget + and/or invoke-time rate limiting (#170).""" registry = CapabilityRegistry() registry.register( Capability( @@ -289,6 +300,8 @@ def _single_driver_kernel(driver: object, *, budget: int | None = None) -> Kerne router=StaticRouter(routes={"cap": [driver.driver_id]}), # type: ignore[attr-defined] token_provider=HMACTokenProvider(secret="test-secret"), budget_manager=BudgetManager(total_budget=budget) if budget is not None else None, + invoke_rate_limits=invoke_rate_limits, + invoke_rate_limiter=invoke_rate_limiter, ) @@ -425,6 +438,284 @@ async def test_stream_timeout_records_error_reason() -> None: assert "timed out" in (traces[0].error or "") +# ── Invoke-time rate limiting (#170) ───────────────────────────────────────── + + +def _ok_driver() -> InMemoryDriver: + driver = InMemoryDriver(driver_id="ok") + driver.register_handler("cap", lambda ctx: {"value": 1}) + return driver + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_disabled_by_default() -> None: + """Without invoke_rate_limits configured, invoke() has no per-call limit.""" + driver = _ok_driver() + k = _single_driver_kernel(driver) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue("cap", "u1") + for _ in range(100): + await k.invoke(token, principal=principal, args={}) # never raises + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_denies_after_window_exceeded() -> None: + """Repeated invoke() on one token is denied once the window limit is hit (#170).""" + driver = _ok_driver() + k = _single_driver_kernel(driver, invoke_rate_limits={SafetyClass.READ: (2, 60.0)}) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue("cap", "u1") + + await k.invoke(token, principal=principal, args={}) + await k.invoke(token, principal=principal, args={}) + with pytest.raises(RateLimitExceeded) as exc_info: + await k.invoke(token, principal=principal, args={}) + assert exc_info.value.reason_code == "invoke_rate_limited" + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_records_deny_trace() -> None: + """A rate-limited invocation still produces an audited ``"deny"`` trace (I-02).""" + driver = _ok_driver() + k = _single_driver_kernel(driver, invoke_rate_limits={SafetyClass.READ: (1, 60.0)}) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue("cap", "u1") + + await k.invoke(token, principal=principal, args={}) + with pytest.raises(RateLimitExceeded): + await k.invoke(token, principal=principal, args={}) + + deny_traces = [t for t in k.list_traces() if t.event_type == "deny"] + assert len(deny_traces) == 1 + assert deny_traces[0].reason_code == "invoke_rate_limited" + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_window_resets() -> None: + """The window slides: an invocation succeeds again once it elapses.""" + time_ref = [0.0] + limiter = RateLimiter(clock=lambda: time_ref[0]) + driver = _ok_driver() + k = _single_driver_kernel( + driver, + invoke_rate_limits={SafetyClass.READ: (1, 10.0)}, + invoke_rate_limiter=limiter, + ) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue("cap", "u1") + + await k.invoke(token, principal=principal, args={}) + with pytest.raises(RateLimitExceeded): + await k.invoke(token, principal=principal, args={}) + + time_ref[0] = 11.0 # advance past the 10s window + await k.invoke(token, principal=principal, args={}) # succeeds again + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_dry_run_does_not_consume() -> None: + """A dry-run never counts against the invoke-time rate limit (#170).""" + driver = _ok_driver() + k = _single_driver_kernel(driver, invoke_rate_limits={SafetyClass.READ: (1, 60.0)}) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue("cap", "u1") + + for _ in range(5): + await k.invoke(token, principal=principal, args={}, dry_run=True) + # The real limit (1) is still fully available after five dry-runs. + await k.invoke(token, principal=principal, args={}) + with pytest.raises(RateLimitExceeded): + await k.invoke(token, principal=principal, args={}) + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_scoped_per_principal_and_capability() -> None: + """The limit is keyed by (principal_id, capability_id), not global.""" + driver = _ok_driver() + k = _single_driver_kernel(driver, invoke_rate_limits={SafetyClass.READ: (1, 60.0)}) + k.register_driver(driver) + token_u1 = k._token_provider.issue("cap", "u1") + token_u2 = k._token_provider.issue("cap", "u2") + + await k.invoke(token_u1, principal=Principal(principal_id="u1"), args={}) + with pytest.raises(RateLimitExceeded): + await k.invoke(token_u1, principal=Principal(principal_id="u1"), args={}) + # u2's own limit is untouched. + await k.invoke(token_u2, principal=Principal(principal_id="u2"), args={}) + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_concurrent_calls_do_not_exceed_limit() -> None: + """Concurrent invoke() calls near the limit boundary never over-admit (no + check-then-record race): exactly `limit` of N concurrent calls succeed.""" + driver = _ok_driver() + k = _single_driver_kernel(driver, invoke_rate_limits={SafetyClass.READ: (3, 60.0)}) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue("cap", "u1") + + results = await asyncio.gather( + *[k.invoke(token, principal=principal, args={}) for _ in range(10)], + return_exceptions=True, + ) + successes = [r for r in results if not isinstance(r, Exception)] + failures = [r for r in results if isinstance(r, RateLimitExceeded)] + assert len(successes) == 3 + assert len(failures) == 7 + + +# ── Signed argument-level constraints (#183) ───────────────────────────────── + + +@pytest.mark.asyncio +async def test_arg_constraint_allowed_keys_violation_denied() -> None: + driver = _ok_driver() + k = _single_driver_kernel(driver) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue( + "cap", "u1", constraints={"args": {"allowed_keys": ["safe_key"]}} + ) + with pytest.raises(TokenScopeError) as exc_info: + await k.invoke(token, principal=principal, args={"safe_key": 1, "extra": 2}) + assert exc_info.value.reason_code == "arg_constraint_violation" + + +@pytest.mark.asyncio +async def test_arg_constraint_allowed_args_pass_through() -> None: + driver = _ok_driver() + k = _single_driver_kernel(driver) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue( + "cap", "u1", constraints={"args": {"allowed_keys": ["safe_key"]}} + ) + frame = await k.invoke(token, principal=principal, args={"safe_key": 1}) + assert frame.action_id != "" + + +@pytest.mark.asyncio +async def test_arg_constraint_pinned_violation_denied() -> None: + driver = _ok_driver() + k = _single_driver_kernel(driver) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue( + "cap", "u1", constraints={"args": {"pinned": {"path": "/safe/x"}}} + ) + with pytest.raises(TokenScopeError, match="pinned"): + await k.invoke(token, principal=principal, args={"path": "/etc/passwd"}) + + +@pytest.mark.asyncio +async def test_arg_constraint_prefix_violation_denied() -> None: + driver = _ok_driver() + k = _single_driver_kernel(driver) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue( + "cap", "u1", constraints={"args": {"prefix": {"path": "/safe/"}}} + ) + with pytest.raises(TokenScopeError, match="prefix"): + await k.invoke(token, principal=principal, args={"path": "/etc/passwd"}) + + +@pytest.mark.asyncio +async def test_arg_constraint_violation_never_reaches_driver() -> None: + """A violating invoke is denied before the driver executes at all.""" + + class _CountingDriver(InMemoryDriver): + def __init__(self) -> None: + super().__init__(driver_id="ok") + self.calls = 0 + self.register_handler("cap", self._handle) + + def _handle(self, ctx: ExecutionContext) -> dict[str, int]: + self.calls += 1 + return {"value": 1} + + driver = _CountingDriver() + k = _single_driver_kernel(driver) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue( + "cap", "u1", constraints={"args": {"allowed_keys": ["safe_key"]}} + ) + with pytest.raises(TokenScopeError): + await k.invoke(token, principal=principal, args={"extra": 1}) + assert driver.calls == 0 + + +@pytest.mark.asyncio +async def test_arg_constraint_records_deny_trace_with_reason_code() -> None: + driver = _ok_driver() + k = _single_driver_kernel(driver) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue( + "cap", "u1", constraints={"args": {"allowed_keys": ["safe_key"]}} + ) + with pytest.raises(TokenScopeError): + await k.invoke(token, principal=principal, args={"extra": 1}) + + deny_traces = [t for t in k.list_traces() if t.event_type == "deny"] + assert len(deny_traces) == 1 + assert deny_traces[0].reason_code == "arg_constraint_violation" + + +@pytest.mark.asyncio +async def test_arg_constraint_dry_run_predicts_same_denial() -> None: + """Dry-run raises the identical error a real invoke would (#183).""" + driver = _ok_driver() + k = _single_driver_kernel(driver) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue( + "cap", "u1", constraints={"args": {"allowed_keys": ["safe_key"]}} + ) + with pytest.raises(TokenScopeError, match="extra"): + await k.invoke(token, principal=principal, args={"extra": 1}, dry_run=True) + + +@pytest.mark.asyncio +async def test_arg_constraint_tampering_invalidates_signature() -> None: + """Mutating a live token's args constraint invalidates its HMAC (I-06).""" + driver = _ok_driver() + k = _single_driver_kernel(driver) + k.register_driver(driver) + principal = Principal(principal_id="u1") + token = k._token_provider.issue( + "cap", "u1", constraints={"args": {"allowed_keys": ["safe_key"]}} + ) + token.constraints["args"]["allowed_keys"] = ["anything"] # tamper post-issuance + with pytest.raises(TokenInvalid): + await k.invoke(token, principal=principal, args={"anything": 1}) + + +def test_validate_arg_constraints_pinned_match_passes() -> None: + from weaver_kernel.kernel._arg_constraints import validate_arg_constraints + + validate_arg_constraints({"args": {"pinned": {"path": "/safe/x"}}}, {"path": "/safe/x"}) + + +def test_validate_arg_constraints_prefix_match_passes() -> None: + from weaver_kernel.kernel._arg_constraints import validate_arg_constraints + + validate_arg_constraints({"args": {"prefix": {"path": "/safe/"}}}, {"path": "/safe/file.txt"}) + + +def test_validate_arg_constraints_prefix_non_string_value_denied() -> None: + from weaver_kernel.kernel._arg_constraints import validate_arg_constraints + + with pytest.raises(TokenScopeError, match="must be a string"): + validate_arg_constraints({"args": {"prefix": {"count": "/safe/"}}}, {"count": 42}) + + # ── Confused-deputy prevention ───────────────────────────────────────────────── diff --git a/tests/test_policy.py b/tests/test_policy.py index 8cdaed6..99f779c 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -11,8 +11,10 @@ from weaver_kernel import ( AgentKernelError, Capability, + CapabilityRegistry, DeclarativePolicyEngine, DefaultPolicyEngine, + Kernel, PolicyConfigError, PolicyDenied, Principal, @@ -585,6 +587,126 @@ def test_rate_limit_window_slides() -> None: eng.evaluate(_req("cap.r"), cap, p, justification="") # should succeed +# ═══════════════════════════════════════════════════════════════════════════════ +# DefaultPolicyEngine.resolve_ttl() (#203) +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_resolve_ttl_none_passes_through() -> None: + """No requested TTL is never a violation, with or without a configured max.""" + eng = DefaultPolicyEngine(max_ttl_s={SafetyClass.READ: 60.0}) + p = Principal(principal_id="u1") + cap = _cap("cap.r", SafetyClass.READ) + assert eng.resolve_ttl(cap, p, None) is None + + +def test_resolve_ttl_no_max_configured_passes_through() -> None: + """Without max_ttl_s configured, any positive ttl_s is returned unchanged.""" + eng = DefaultPolicyEngine() + p = Principal(principal_id="u1") + cap = _cap("cap.r", SafetyClass.READ) + assert eng.resolve_ttl(cap, p, 999999.0) == 999999.0 + + +def test_resolve_ttl_within_max_returned_unchanged() -> None: + eng = DefaultPolicyEngine(max_ttl_s={SafetyClass.READ: 120.0}) + p = Principal(principal_id="u1") + cap = _cap("cap.r", SafetyClass.READ) + assert eng.resolve_ttl(cap, p, 60.0) == 60.0 + + +def test_resolve_ttl_rejects_non_positive() -> None: + eng = DefaultPolicyEngine() + p = Principal(principal_id="u1") + cap = _cap("cap.r", SafetyClass.READ) + with pytest.raises(PolicyDenied, match="must be positive"): + eng.resolve_ttl(cap, p, 0.0) + with pytest.raises(PolicyDenied, match="must be positive"): + eng.resolve_ttl(cap, p, -1.0) + + +def test_resolve_ttl_rejects_over_max_with_stable_reason_code() -> None: + eng = DefaultPolicyEngine(max_ttl_s={SafetyClass.READ: 60.0}) + p = Principal(principal_id="u1") + cap = _cap("cap.r", SafetyClass.READ) + with pytest.raises(PolicyDenied, match="exceeds") as exc_info: + eng.resolve_ttl(cap, p, 3600.0) + assert exc_info.value.reason_code == "ttl_exceeds_max" + + +def test_resolve_ttl_max_is_per_safety_class() -> None: + """A max_ttl_s entry for READ must not constrain WRITE.""" + eng = DefaultPolicyEngine(max_ttl_s={SafetyClass.READ: 60.0}) + p = Principal(principal_id="u1", roles=["writer"]) + cap = _cap("cap.w", SafetyClass.WRITE) + assert eng.resolve_ttl(cap, p, 3600.0) == 3600.0 + + +def test_grant_capability_ttl_s_sets_token_expiry( + kernel: Kernel, reader_principal: Principal +) -> None: + """grant_capability(ttl_s=...) yields a token expiring ~ttl_s after issuance.""" + request = kernel.request_capabilities("list invoices")[0] + grant = kernel.grant_capability(request, reader_principal, justification="x", ttl_s=60.0) + delta = (grant.token.expires_at - grant.token.issued_at).total_seconds() + assert delta == pytest.approx(60.0) + + +def test_grant_capability_default_ttl_unchanged_without_ttl_s( + kernel: Kernel, reader_principal: Principal +) -> None: + """Omitting ttl_s preserves the token provider's own default (1 hour).""" + request = kernel.request_capabilities("list invoices")[0] + grant = kernel.grant_capability(request, reader_principal, justification="x") + delta = (grant.token.expires_at - grant.token.issued_at).total_seconds() + assert delta == pytest.approx(3600.0) + + +def test_grant_capability_ttl_over_policy_max_denied() -> None: + """An excessive ttl_s is denied via the policy's resolve_ttl, not silently clamped.""" + registry = CapabilityRegistry() + registry.register(_cap("cap.r", SafetyClass.READ)) + kernel = Kernel(registry, policy=DefaultPolicyEngine(max_ttl_s={SafetyClass.READ: 60.0})) + p = Principal(principal_id="u1") + request = CapabilityRequest(capability_id="cap.r", goal="test") + with pytest.raises(PolicyDenied, match="exceeds"): + kernel.grant_capability(request, p, justification="x", ttl_s=3600.0) + + +class _NoResolveTtlEngine: + """A minimal PolicyEngine that implements only `evaluate()` — no `resolve_ttl`. + + Verifies grant_capability(ttl_s=...) degrades gracefully (ttl_s passed + through unvalidated) against an engine that predates #203, confirming + resolve_ttl is a non-breaking, optional Protocol extension. + """ + + def evaluate( + self, + request: CapabilityRequest, + capability: Capability, + principal: Principal, + *, + justification: str, + ): + from weaver_kernel.models import PolicyDecision + from weaver_kernel.policy_reasons import AllowReason + + return PolicyDecision(allowed=True, reason="ok", reason_code=str(AllowReason.RULE_ALLOW)) + + +def test_grant_capability_ttl_s_passthrough_without_resolve_ttl() -> None: + """A policy engine without resolve_ttl leaves ttl_s unvalidated (non-breaking).""" + registry = CapabilityRegistry() + registry.register(_cap("cap.r", SafetyClass.READ)) + kernel = Kernel(registry, policy=_NoResolveTtlEngine()) + p = Principal(principal_id="u1") + request = CapabilityRequest(capability_id="cap.r", goal="test") + grant = kernel.grant_capability(request, p, justification="x", ttl_s=42.0) + delta = (grant.token.expires_at - grant.token.issued_at).total_seconds() + assert delta == pytest.approx(42.0) + + # ═══════════════════════════════════════════════════════════════════════════════ # DefaultPolicyEngine.explain() # ═══════════════════════════════════════════════════════════════════════════════ @@ -817,6 +939,26 @@ def test_declarative_constraints_merged() -> None: assert dec.constraints["max_rows"] == 10 +def test_declarative_args_constraint_attached_and_enforced() -> None: + """A declarative rule's nested `constraints.args` (#183) round-trips into a + signed token and is enforced by Kernel.invoke — no parser/engine changes + needed, since `constraints` is already a free-form mapping (#183 item 3). + """ + engine = _dce( + [ + { + "name": "scoped-write", + "match": {"safety_class": ["WRITE"]}, + "action": "allow", + "constraints": {"args": {"allowed_keys": ["ticket_id", "status"]}}, + } + ] + ) + p = Principal(principal_id="u1") + dec = engine.evaluate(_req("c"), _cap("c", SafetyClass.WRITE), p, justification="") + assert dec.constraints["args"] == {"allowed_keys": ["ticket_id", "status"]} + + # ── config validation errors ────────────────────────────────────────────────── diff --git a/tests/test_tokens.py b/tests/test_tokens.py index d02600e..9a0b650 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -7,6 +7,7 @@ import pytest from weaver_kernel import ( + AgentKernelError, HMACTokenProvider, TokenExpired, TokenInvalid, @@ -241,3 +242,176 @@ def test_track_and_sweep_accept_naive_datetimes() -> None: # Naive 'now' after expiry removes it. assert store.sweep_expired(datetime.datetime(2099, 1, 2)) == 1 assert not store.is_revoked("t1") + + +# ── Signing-key rotation (#185) ────────────────────────────────────────────── + + +def test_legacy_single_secret_unaffected(provider: HMACTokenProvider) -> None: + """A provider built with `secret=...` (no rotation) behaves exactly as before.""" + token = provider.issue("cap.x", "user-1") + assert token.key_id == "" + provider.verify(token, expected_principal_id="user-1", expected_capability_id="cap.x") + + +def test_issue_stamps_active_key_id() -> None: + provider = HMACTokenProvider(secrets={"v1": "secret-v1"}, active_key_id="v1") + token = provider.issue("cap.x", "user-1") + assert token.key_id == "v1" + + +def test_rotated_key_verifies_during_overlap_window() -> None: + """A token issued under the previous active key still verifies once a new + key becomes active, as long as both keys remain in the ring (#185).""" + old_provider = HMACTokenProvider(secrets={"v1": "secret-v1"}, active_key_id="v1") + old_token = old_provider.issue("cap.x", "user-1") + + rotated_provider = HMACTokenProvider( + secrets={"v1": "secret-v1", "v2": "secret-v2"}, active_key_id="v2" + ) + # Should not raise — v1 is still a known key, just no longer active. + rotated_provider.verify( + old_token, expected_principal_id="user-1", expected_capability_id="cap.x" + ) + + new_token = rotated_provider.issue("cap.x", "user-1") + assert new_token.key_id == "v2" + rotated_provider.verify( + new_token, expected_principal_id="user-1", expected_capability_id="cap.x" + ) + + +def test_verify_unknown_key_id_fails_closed() -> None: + """A token whose key_id was retired (or never existed) fails closed as TokenInvalid.""" + from dataclasses import replace + + provider = HMACTokenProvider(secrets={"v2": "secret-v2"}, active_key_id="v2") + token = provider.issue("cap.x", "user-1") + orphaned = replace(token, key_id="v1-retired") + with pytest.raises(TokenInvalid, match="Unknown signing key"): + provider.verify(orphaned, expected_principal_id="user-1", expected_capability_id="cap.x") + + +def test_verify_logs_when_key_is_not_active(caplog: pytest.LogCaptureFixture) -> None: + """Verifying a token signed under a non-active (overlap-window) key is logged + at INFO — with the key id, never the secret — so operators can tell when a + rotation's old key is safe to retire.""" + import logging + + old_provider = HMACTokenProvider(secrets={"v1": "secret-v1"}, active_key_id="v1") + v1_token = old_provider.issue("cap.x", "user-1") + + rotated_provider = HMACTokenProvider( + secrets={"v1": "secret-v1", "v2": "secret-v2"}, active_key_id="v2" + ) + active_token = rotated_provider.issue("cap.x", "user-1") + + with caplog.at_level(logging.INFO): + rotated_provider.verify( + v1_token, expected_principal_id="user-1", expected_capability_id="cap.x" + ) + non_active_logs = [ + r for r in caplog.records if r.getMessage() == "token_verified_non_active_key" + ] + assert len(non_active_logs) == 1 + assert non_active_logs[0].key_id == "v1" + assert "secret-v1" not in str(non_active_logs[0].__dict__) + + # The active key never triggers this log. + caplog.clear() + with caplog.at_level(logging.INFO): + rotated_provider.verify( + active_token, expected_principal_id="user-1", expected_capability_id="cap.x" + ) + assert not any(r.getMessage() == "token_verified_non_active_key" for r in caplog.records) + + +def test_key_ring_requires_active_key_id_when_ambiguous() -> None: + """Configuring multiple secrets without an active_key_id is a config error, + not a silent pick of an arbitrary key.""" + with pytest.raises(AgentKernelError, match="active_key_id is required"): + HMACTokenProvider(secrets={"v1": "s1", "v2": "s2"}) + + +def test_key_ring_rejects_unknown_active_key_id() -> None: + with pytest.raises(AgentKernelError, match="not a key in the configured secrets"): + HMACTokenProvider(secrets={"v1": "s1"}, active_key_id="nope") + + +# ── Typed errors from CapabilityToken.from_dict (#200) ─────────────────────── + + +def _valid_token_dict() -> dict[str, object]: + return { + "token_id": "t1", + "capability_id": "cap.x", + "principal_id": "user-1", + "issued_at": "2026-01-01T00:00:00+00:00", + "expires_at": "2026-01-01T01:00:00+00:00", + "constraints": {}, + "audit_id": "", + "signature": "sig", + "key_id": "", + } + + +def test_from_dict_valid_roundtrip_unchanged(provider: HMACTokenProvider) -> None: + """A well-formed to_dict() -> from_dict() round-trip still works exactly as before.""" + from weaver_kernel.tokens import CapabilityToken + + token = provider.issue("cap.x", "user-1", constraints={"foo": "bar"}) + restored = CapabilityToken.from_dict(token.to_dict()) + assert restored == token + + +@pytest.mark.parametrize("missing_field", ["token_id", "capability_id", "principal_id"]) +def test_from_dict_missing_required_field_raises_token_invalid(missing_field: str) -> None: + from weaver_kernel.tokens import CapabilityToken + + data = _valid_token_dict() + del data[missing_field] + with pytest.raises(TokenInvalid, match=f"missing field '{missing_field}'"): + CapabilityToken.from_dict(data) + + +@pytest.mark.parametrize("field_name", ["token_id", "capability_id", "principal_id"]) +def test_from_dict_wrong_type_field_raises_token_invalid(field_name: str) -> None: + from weaver_kernel.tokens import CapabilityToken + + data = _valid_token_dict() + data[field_name] = 12345 # not a string + with pytest.raises(TokenInvalid, match=f"field '{field_name}' must be a string"): + CapabilityToken.from_dict(data) + + +@pytest.mark.parametrize("ts_field", ["issued_at", "expires_at"]) +def test_from_dict_invalid_timestamp_raises_token_invalid(ts_field: str) -> None: + from weaver_kernel.tokens import CapabilityToken + + data = _valid_token_dict() + data[ts_field] = "not-a-timestamp" + with pytest.raises(TokenInvalid, match=f"invalid timestamp in field '{ts_field}'"): + CapabilityToken.from_dict(data) + + +def test_from_dict_non_dict_constraints_raises_token_invalid() -> None: + from weaver_kernel.tokens import CapabilityToken + + data = _valid_token_dict() + data["constraints"] = ["not", "a", "dict"] + with pytest.raises(TokenInvalid, match="'constraints' must be an object"): + CapabilityToken.from_dict(data) + + +def test_from_dict_missing_optional_fields_use_defaults() -> None: + """audit_id/signature/key_id default to '' when absent, as before.""" + from weaver_kernel.tokens import CapabilityToken + + data = _valid_token_dict() + del data["audit_id"] + del data["signature"] + del data["key_id"] + token = CapabilityToken.from_dict(data) + assert token.audit_id == "" + assert token.signature == "" + assert token.key_id == ""