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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
deny→allow, and reason-code flips deterministically. Rate-limit-dependent flips
are surfaced separately (`DecisionDiff.rate_limited`).
Companion: `examples/trace_replay_demo.py`.
- **Capability-token lifecycle hardening.** A grouped pass over token issuance,
rotation, and invoke-time enforcement:
- **Signing-key rotation (#185).** `HMACTokenProvider(secrets={key_id: secret},
active_key_id=...)` signs new tokens under one key while verifying tokens
signed under others during an overlap window, so `WEAVER_KERNEL_SECRET` can
rotate without invalidating every outstanding token at once. The signing
`key_id` is inside the signed payload (tamper-evident); an unknown key id
fails closed as `TokenInvalid`. A new `WEAVER_KERNEL_SECRETS` (JSON
`{key_id: secret}`) / `WEAVER_KERNEL_ACTIVE_KEY` env pair configures it, and a
non-active-key verification logs `token_verified_non_active_key` (key id only,
never the secret) so operators can tell when a key is safe to retire.
- **Per-grant TTL (#203).** `Kernel.grant_capability(..., ttl_s=...)` sets a
token's lifetime per grant; `DefaultPolicyEngine(max_ttl_s=...)` (a single cap
or per-safety-class map) bounds it. A non-positive or over-maximum request is
**denied** with a stable reason code (`invalid_constraint` / `ttl_exceeded`),
never silently clamped.
- **Signed argument-level constraints (#183).** A token's `constraints["args"]`
(`allowed_keys`, `pinned`, `prefix`) is enforced by `invoke()` and
`invoke_stream()` **before** the driver runs and budget is reserved, raising
`TokenScopeError` (`arg_constraint_violation`) with an audited failure trace.
Dry-run predicts the identical outcome. The declarative policy engine needed
no changes — `constraints` already flows into the issued token.
- **Opt-in per-invocation rate limiting (#170).** `Kernel(invoke_rate_limits=
{SafetyClass: (limit, window_s)})` adds an invoke-time sliding-window limit,
independent of and additional to the grant-time limit. **Default off.** The
check-then-record pair runs with no `await` between them, so concurrent
invokes cannot over-admit; dry-run never consumes it.
- **Typed `CapabilityToken.from_dict` errors (#200).** A malformed serialized
token (missing field, wrong type, invalid timestamp, non-object
`constraints`) now raises `TokenInvalid` with a descriptive message instead
of a bare `KeyError`/`ValueError`. Valid round-trips are unchanged.
- **Token-format evolution ADR (#224).** `docs/adr/0001-token-signing-evolution.md`
evaluates HMAC (status quo), macaroon-style caveat chaining, and Biscuit
against the kernel's invariants with measured numbers, and recommends staying
HMAC + re-issuance now (macaroon-chaining as the documented future path,
Biscuit deferred). No code or dependency change.

### Changed
- **Capability-token signed payload now includes `key_id` (#185).** A token
issued by pre-upgrade code fails verification after this deploys — the signed
payload shape differs even under the same secret. Accepted as a break given the
pre-1.0 alpha status and the default 1-hour token TTL; legacy single-secret
*configuration* (`secret=` / `WEAVER_KERNEL_SECRET`) keeps working unchanged.
The `HMACTokenProvider` implementation moved to `weaver_kernel._hmac_provider`
(re-exported from `weaver_kernel.tokens`; the logger name is unchanged).
- **Removed the private `policy._DEFAULT_RATE_LIMITS` / `_SERVICE_RATE_MULTIPLIER`
aliases (part of #196).** Internal call sites use the canonical
`rate_limit.DEFAULT_RATE_LIMITS` / `SERVICE_RATE_MULTIPLIER`. Private surface;
no deprecation cycle.
- **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
Expand Down
111 changes: 111 additions & 0 deletions docs/adr/0001-token-signing-evolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# ADR 0001 — Capability-token signing format evolution

- **Status:** Accepted (investigation; no production code change)
- **Tracks:** #224 · **Feeds:** #129 (delegated/attenuated grants), #103 (production-hardening roadmap)
- **Related code:** `tokens.py`, `_token_signing.py`, `_hmac_provider.py`, `federation_discovery.py`

## Context

agent-kernel authorizes tool calls with HMAC-SHA256 capability tokens. Today a
token binds `principal + capability + constraints` under a single shared secret
(now a rotatable key-ring, #185). Two roadmap directions push on that format:

- **Delegated, attenuated grants (#129):** an agent that holds a grant wants to
hand a *narrower* grant to a sub-agent. HMAC cannot do this offline — narrowing
requires re-issuance by the holder of the signing secret.
- **Cross-boundary verification (federation, manifest signing):** a peer that
should *verify* a token must currently hold the *signing* secret, which is the
wrong trust boundary for public verification.

This ADR evaluates whether to evolve the token format, and records a
recommendation. It changes no production code.

## Decision drivers (from the kernel's invariants)

1. **I-06** — a token must keep binding `principal + capability + constraints`;
any format must preserve tamper-evidence of those fields.
2. **Minimal-dependency policy** (AGENTS.md, `invariants.md` #6) — runtime deps
are `httpx` + `pydantic` only; a mandatory crypto dependency is a high bar.
3. **Determinism** — no randomness on the verification path.
4. **Revocation model** — the kernel relies on a server-side revocation store
checked before signature (`_hmac_provider.verify` step 0). Offline-attenuable
formats weaken the default "revoke by id" posture unless paired with it.
5. **`explain()` transparency** — denials must stay human- and agent-legible;
a Datalog policy layer is powerful but less transparent than the current
first-match rule chain.
6. **0.x migration cost** — pre-1.0, a format break is acceptable (tokens live
≤1h by default) but should not be gratuitous.

## Options considered

Measurements below are from this repo (`python 3.11`, `_token_signing.sign`),
recorded so the tradeoff is concrete rather than asserted.

### A. Status quo — shared-secret HMAC + re-issuance-based attenuation

- **Binding / determinism / deps:** all satisfied; stdlib `hmac` only.
- **Attenuation:** via re-issuance — a "delegation" is just `grant_capability`
with narrower `constraints`. In the kernel's current single-process, in-process
deployment this is a function call, not a network round-trip, so offline
attenuation buys little.
- **Measured:** token ≈ **378 bytes** JSON (299-byte signable payload);
**≈12.0 µs/verify**, ≈20.4 µs/issue.
- **Revocation:** native (server-side store, checked before crypto).

### B. Macaroon-style HMAC caveat chaining (in-tree, stdlib only)

- **Binding / determinism / deps:** all satisfied — caveats chain with stdlib
`hmac` (no new dependency). Verified with a micro-prototype in this ADR's
investigation.
- **Attenuation:** *offline* — a holder appends a caveat and re-chains the
signature **without** the root secret. This is the capability HMAC lacks.
- **Measured (3-caveat prototype):** token ≈ **234 bytes**; **≈8.0 µs/verify**.
Offline attenuation (narrowing `args.path.prefix` from `/safe/` to
`/safe/reports/` with no root secret) confirmed working.
- **Costs:** first-party-caveat predicates become a small language to design and
keep deterministic; revocation of a *delegated* leaf needs an identifier
scheme layered on top of the existing store.

### C. Biscuit (public-key + Datalog attenuation, behind an extra)

- **Binding:** satisfied, plus public-key verification (verify without the
signing secret) — the one thing neither A nor B offers.
- **Deps:** a **mandatory third-party library** with native crypto — directly
against the minimal-dependency invariant; only viable behind an optional extra.
- **Determinism / transparency:** Datalog is expressive but reduces
`explain()`-style transparency and adds a non-trivial evaluation surface.
- **Revocation:** offline-verifiable tokens are the *hardest* to revoke; needs a
parallel revocation channel.

## Decision

**Stay on HMAC (Option A) for now**, strengthened by the key-ring rotation
shipped in #185 (which closes the "can't rotate the secret" gap that most
motivated looking elsewhere).

- **Defer Biscuit (C).** Its unique win — public-key verification — has no
current consumer, and its mandatory dependency + weakened default revocation
posture conflict with two invariants. Revisit only if cross-trust-boundary
*offline* verification becomes a real requirement.
- **Keep macaroon-style chaining (B) as the documented evolution path** if an
**offline** delegation requirement actually materializes (e.g. once a remote
kernel mode, #227, makes delegation a network hop rather than a function call).
The `constraints["args"]` vocabulary added in #183 (`allowed_keys` / `pinned` /
`prefix`) is deliberately shaped to be reusable as a first-party caveat
predicate language if that day comes.
- **Implement delegation (#129) via re-issuance** in the meantime: a delegation
request is `grant_capability` with narrower `constraints`, which stays inside
the existing policy → token → revocation pipeline.

## Consequences

- No dependency change; no token-format change beyond #185's `key_id`.
- #129 proceeds on re-issuance semantics; #103 records rotation as its first
shipped hardening slice.
- The `TokenProvider` Protocol remains the seam: any future B/C provider slots in
behind it with a dual-verification window, without kernel-wide changes.

## Revisit triggers

- A concrete need for **offline** attenuation or **secret-less** verification.
- A remote/sidecar kernel mode (#227) that turns delegation into a network hop.
10 changes: 10 additions & 0 deletions docs/agent-context/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ tag is **silently ignored** — capabilities tagged with it pass policy without

**Rule:** When adding a `SensitivityTag`, always add a matching policy rule and test.

### Invoke-time enforcement placement
Argument-constraint enforcement (#183) and the optional per-invocation rate limit
(#170) run in `Kernel.invoke`/`Kernel.invoke_stream` **after** `verify()` and
**before** the driver runs or budget is reserved (`kernel/_constraints.py`). A
violation on the real path records a failure `ActionTrace` before raising, so I-02
holds for denied executions; `dry_run=True` evaluates the same checks for parity
but records no rate-limit usage and writes no trace. Both single-shot and
streaming entry points must call `run_pre_invoke_checks` — a new execution entry
point must call it too, or it silently bypasses argument scoping and rate limits.

### Dry-run response-mode parity
`Kernel.invoke(dry_run=True)` reports the response mode the caller would actually
get at real-invoke time. The Firewall downgrades `raw` to `summary` for non-admin
Expand Down
84 changes: 83 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,93 @@
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)
- `constraints` — max_rows, allowed_fields, `args`, etc. (signed into the token)
- `expires_at` — validity window
- `key_id` — which signing key produced the signature (for rotation, below)

Any change to these fields invalidates the HMAC signature.

### Per-grant TTL (#203)

`Kernel.grant_capability(request, principal, justification=..., ttl_s=...)` sets a
token's lifetime per grant instead of the fixed provider default (3600 s). Least
privilege is temporal as well as scoped — a one-shot lookup need not yield an
hour-long credential. Configure a ceiling on the policy engine:

```python
from weaver_kernel import DefaultPolicyEngine, SafetyClass

# One cap for every safety class, or a per-class map:
DefaultPolicyEngine(max_ttl_s=300)
DefaultPolicyEngine(max_ttl_s={SafetyClass.READ: 60, SafetyClass.DESTRUCTIVE: 30})
```

A non-positive `ttl_s`, or one above the policy maximum, is **denied** (reason
codes `invalid_constraint` / `ttl_exceeded`) and audited as a `"deny"` trace —
never silently clamped, so a caller never receives less privilege than it can see.

### Signed argument constraints (#183)

Beyond authorizing a *capability*, a token can pin the *arguments* an invocation
may pass, signed into `constraints["args"]` and enforced at `invoke()` /
`invoke_stream()` time (before the driver runs and budget is reserved). The v1
vocabulary is deliberately tiny and deterministic (top-level keys only):

| Rule | Meaning |
|------|---------|
| `allowed_keys` | Every argument key must appear in this list. |
| `pinned` | Each named key must be present and exactly equal to the given value. |
| `prefix` | Each named key must be a string starting with the given prefix. |

A violation raises `TokenScopeError` (`reason_code = arg_constraint_violation`)
with an audited failure trace and never reaches the driver; `dry_run=True`
predicts the identical outcome. This is the difference between "may call the
refund tool" and "may refund order #123".

### Signing-key rotation (#185)

`HMACTokenProvider` verifies against a small key-ring so `WEAVER_KERNEL_SECRET`
can rotate without invalidating every outstanding token at once:

```python
# Sign new tokens under k2; keep k1 for the overlap window so tokens signed
# under it still verify. Retire k1 once max TTL has elapsed.
HMACTokenProvider(secrets={"k1": old, "k2": new}, active_key_id="k2")
```

The signing `key_id` is part of the signed payload (tamper-evident); a token
declaring a key id not in the ring fails closed as `TokenInvalid`. Verifying a
non-active-key token logs `token_verified_non_active_key` (key id only, never the
secret) so operators can see when the previous key is safe to retire.

**Secret resolution precedence** (first match wins): the `secrets=` /`secret=`
constructor argument → `WEAVER_KERNEL_SECRETS` (JSON `{key_id: secret}`, with
`WEAVER_KERNEL_ACTIVE_KEY` naming the active key) → the legacy single
`WEAVER_KERNEL_SECRET` → a random development secret (with a one-time warning).
The resolved secret is never logged.

**Rotation runbook:** add the new key alongside the old (`secrets={old, new}`) →
set `active_key_id`/`WEAVER_KERNEL_ACTIVE_KEY` to the new key → wait for the
maximum token TTL so no live token is still signed under the old key → drop the
old key from the ring.

### Per-invocation rate limiting (#170)

The policy engine rate-limits at *grant* time; a multi-use token can then drive
many `invoke()` calls until it expires. For runaway-loop and abuse protection,
`Kernel(invoke_rate_limits={SafetyClass.READ: (limit, window_s)})` adds an
independent sliding-window limit on the *execution* path (default off). It is
enforced identically for `invoke()` and `invoke_stream()`; an exhausted limit
raises `PolicyDenied` (`reason_code = rate_limited`) with an audited failure
trace, and `dry_run=True` never consumes the window.

### Token deserialization (#200)

`CapabilityToken.from_dict` validates untrusted input and raises the typed
`TokenInvalid` (never a bare `KeyError`/`ValueError`) on a missing field, wrong
type, malformed timestamp, or non-object `constraints` — tokens cross process
boundaries, so a malformed blob is an expected input class, not a crash.

## 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`.
Expand Down
6 changes: 3 additions & 3 deletions src/weaver_kernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@
AgentKernelError,
TokenExpired, TokenInvalid, TokenScopeError, TokenRevoked,
PolicyDenied, PolicyConfigError,
DriverError, FirewallError, AdapterParseError,
BudgetExhausted, BudgetConfigError,
DriverError, FirewallError, AdapterParseError, BudgetExhausted, BudgetConfigError,
CapabilityNotFound, CapabilityAlreadyRegistered,
HandleNotFound, HandleExpired, HandleTooLarge, HandleConstraintViolation,
NamespaceNotFound, FederationError, ManifestError, ManifestSignatureError,
Expand All @@ -69,6 +68,7 @@
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version

from ._hmac_provider import HMACTokenProvider
from .adapters import AnthropicMiddleware, OpenAIMiddleware
from .drivers.base import Driver, ExecutionContext
from .drivers.http import HTTPDriver
Expand Down Expand Up @@ -177,7 +177,7 @@
TraceStoreProtocol,
verify_chain,
)
from .tokens import CapabilityToken, HMACTokenProvider
from .tokens import CapabilityToken
from .trace import (
TRACE_EXPORT_SCHEMA,
TRACE_EXPORT_VERSION,
Expand Down
Loading