From 0c45a615d6221970f51743e763ebf75546b2dc46 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 11 Aug 2026 07:00:54 +0100 Subject: [PATCH 1/9] docs: add vulnerability disclosure policy --- SECURITY.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..124dfd2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,83 @@ +# Security Policy + +Weaver Kernel sits on an agent-action enforcement boundary, so responsible vulnerability reporting is especially valuable. + +## Supported versions + +Security fixes are prioritized for the **latest published release** and current `main`. + +| Version | Security support | +| --- | --- | +| Latest published release | Supported | +| Current `main` / unreleased next version | Fixes developed here before release | +| Older published releases | Best effort; reporters may be asked to reproduce on the latest release | + +A security fix may require a fail-closed behavior change even when that is breaking. See [`docs/versioning.md`](docs/versioning.md) for the compatibility policy and [`docs/security-contract.md`](docs/security-contract.md) for the guarantees Kernel currently claims. + +## Report a vulnerability privately + +**Do not open a public issue for a suspected vulnerability.** + +Use GitHub's private repository-security channel: + +1. Open the repository's **Security** tab. +2. Choose **Advisories** / **Report a vulnerability** (wording depends on your GitHub permissions/UI). +3. Create a private draft advisory with the report. + +Direct link when available: + +`https://github.com/dgenio/agent-kernel/security/advisories/new` + +Include, where possible: + +- affected Weaver Kernel version or commit SHA; +- affected integration/deployment mode (for example MCP stdio, MCP HTTP, embedded wrapper); +- a minimal reproducer or failing test; +- the security property you expected to hold; +- whether driver/tool execution actually occurred; +- whether the issue crosses a principal, capability, constraint, output, audit, or deployment-consistency boundary; +- any suggested remediation, if you have one. + +Please avoid including real credentials, secrets, customer data, or destructive production steps. A synthetic reproducer is strongly preferred. + +## Response expectations + +This is an open-source project, not a staffed security service and there is no contractual SLA. Maintainers nevertheless aim to: + +- acknowledge a well-formed private report within **7 calendar days**; +- confirm whether the report reproduces or request additional detail; +- coordinate disclosure timing for confirmed vulnerabilities; +- credit reporters when they want attribution and disclosure is appropriate. + +Complex fixes can take longer, particularly when they affect protocol compatibility or public security contracts. The maintainer will prefer an accurate fix and explicit limitation over a rushed claim that the issue is resolved. + +## Security scope + +High-value reports include, but are not limited to: + +- a Kernel-mediated driver executing without valid authorization; +- a capability token being usable by a different principal or capability; +- signed constraints being widened or bypassed; +- malformed security configuration causing a fail-open path; +- raw/sensitive driver output bypassing the documented Context Firewall boundary; +- handle expansion escaping the original grant/principal constraints; +- audit/evidence paths leaking raw secrets or omitting an execution that the contract says must be recorded; +- protocol/integration behavior that silently weakens an advertised enforcement guarantee; +- concurrency or state-consistency behavior that violates a documented supported deployment profile. + +## Important non-vulnerabilities / non-goals + +Please read the [Security Contract](docs/security-contract.md) before reporting a boundary mismatch. In particular, Weaver Kernel currently does **not** claim: + +- to make an LLM trustworthy; +- to prevent execution paths that bypass the in-process Kernel mediation point; +- to be a VM/container/network sandbox; +- to authenticate a `Principal` on behalf of the host; +- to provide globally consistent revocation/rate-limit/handle state across independent workers unless the deployed backing state establishes that property; +- to make heuristic PII/secret redaction a formal confidentiality proof. + +A surprising result inside one of those non-goals can still be worth discussing, but it may be a product/design issue rather than a vulnerability. + +## Public security issues + +Once a vulnerability is fixed/disclosed, public follow-up work may be tracked in normal issues when doing so no longer exposes an unpatched weakness. Security-sensitive implementation details should stay in the private advisory until coordinated disclosure. From fab28759043fe054e31a0ee0a6e43cd9bd44775e Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 11 Aug 2026 07:01:14 +0100 Subject: [PATCH 2/9] docs: add contributor code of conduct --- CODE_OF_CONDUCT.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3e26570 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Code of Conduct + +We want Weaver Kernel to be a technically rigorous, welcoming open-source project where disagreement is useful and participation is safe. + +## Expected behavior + +Participants should: + +- treat other contributors with respect, including when reviewing or rejecting ideas; +- critique code, claims and designs rather than people; +- make room for different levels of experience and different technical backgrounds; +- state uncertainty and evidence honestly, especially for security claims; +- assume good faith while still reviewing security-sensitive changes critically; +- avoid harassment, discrimination, threats, sexualized attention, doxxing, stalking, or sustained personal attacks; +- respect a contributor's decision to disengage from a conversation. + +Technical disagreement is welcome. Strong criticism of a design is not a conduct violation when it remains focused on the work and is expressed professionally. + +## Scope + +This code applies in repository issues, pull requests, reviews, security advisories, and other project spaces, as well as when someone is publicly representing the project. + +## Reporting conduct concerns + +Do not force a reporter to disclose a sensitive conduct concern in a public issue. + +Until the project has a dedicated private conduct mailbox, use the repository's private GitHub advisory channel as the confidential maintainer contact: + +`https://github.com/dgenio/agent-kernel/security/advisories/new` + +Prefix the advisory title with **`[Conduct]`** so it is triaged as a conduct report rather than a vulnerability report. Do not include unrelated credentials or sensitive production data. + +If a report concerns the repository maintainer directly and the reporter does not consider that channel appropriate, use GitHub's platform abuse/reporting mechanisms instead. + +## Enforcement + +Maintainers may edit or remove comments, decline contributions, issue a warning, temporarily restrict participation, or permanently ban participation when behavior materially violates this code. Enforcement should be proportionate, documented privately where appropriate, and avoid disclosing information a reporter expected to remain confidential. + +Retaliation against someone for making a good-faith report is itself unacceptable. + +## Attribution + +This policy follows the principles and enforcement intent of widely used open-source contributor codes of conduct, including the Contributor Covenant, while keeping the project-specific reporting route explicit. From cf872e8b5637f6a2709b68ae8d023188cdf3b25b Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 11 Aug 2026 07:01:33 +0100 Subject: [PATCH 3/9] community: add structured bug report form --- .github/ISSUE_TEMPLATE/bug_report.yml | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ed1472f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,78 @@ +name: Bug report +description: Report a reproducible problem in Weaver Kernel +title: "bug: " +body: + - type: markdown + attributes: + value: | + Thanks for helping improve Weaver Kernel. If this may be a security vulnerability, **do not continue here**; use the private security-reporting link in the issue chooser instead. + - type: input + id: version + attributes: + label: Weaver Kernel version / commit + description: Package version (for example 0.x.y) or exact commit SHA. + placeholder: "0.x.y or commit SHA" + validations: + required: true + - type: input + id: python + attributes: + label: Python version + placeholder: "3.12.x" + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Kernel / invocation + - Policy / authorization + - Capability tokens / constraints + - Context Firewall / Frames / handles + - MCP integration + - Other integration / adapter + - Audit / traces + - Packaging / installation + - Documentation + - Other + validations: + required: true + - type: textarea + id: repro + attributes: + label: Minimal reproduction + description: Prefer the smallest runnable snippet or test that demonstrates the problem. Remove credentials and real sensitive data. + render: python + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect Kernel to do? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: Include the exact exception/reason code/output when useful. + validations: + required: true + - type: textarea + id: environment + attributes: + label: Integration / environment details + description: Relevant SDK versions, transport (stdio/HTTP), OS, framework, worker count, or deployment assumptions. + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched existing open and closed issues for the same problem. + required: true + - label: This report does not contain credentials, customer data, or other secrets. + required: true + - label: I read the Security Contract if this concerns an enforcement/security boundary. + required: false From 498854b96cc596e6282c22c45955f669fda79d25 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 11 Aug 2026 07:01:50 +0100 Subject: [PATCH 4/9] community: add structured feature request form --- .github/ISSUE_TEMPLATE/feature_request.yml | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..5554699 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,53 @@ +name: Feature request +description: Propose a problem worth solving in Weaver Kernel +title: "proposal: " +body: + - type: markdown + attributes: + value: | + Weaver Kernel uses a gate-driven roadmap. Please lead with the **user/security problem and evidence**, not only a desired API. Speculative platform expansion may be closed until adopter pull exists. + - type: textarea + id: problem + attributes: + label: Problem + description: What concrete problem cannot be solved adequately with the current Kernel, the host framework, or an existing IAM/policy system? + validations: + required: true + - type: textarea + id: users + attributes: + label: Who needs this? + description: Describe the real adopter/use case. Links to downstream code or reproducible examples are especially useful. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Evidence / current workaround + description: What have you tried? What fails or becomes unsafe/expensive today? + - type: textarea + id: proposal + attributes: + label: Smallest useful change + description: What is the narrowest change that would solve the problem without broadening Kernel into a general agent platform? + validations: + required: true + - type: textarea + id: security + attributes: + label: Security / compatibility impact + description: Does this change a principal, capability, constraint, execution, output, audit, protocol, or deployment guarantee? Could it introduce a fail-open path? + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Include native framework controls, external IAM/policy, AgentFence/gateway, or doing nothing when relevant. + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched open and closed issues for related work. + required: true + - label: I read ROADMAP.md and understand this may be deferred if it is Gate-7 expansion without adopter pull. + required: true From 64c05bd4ce404756e0e2bf6eea2e61ac7ade9407 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 11 Aug 2026 07:02:04 +0100 Subject: [PATCH 5/9] community: route security reports privately --- .github/ISSUE_TEMPLATE/config.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..690dd91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability privately + url: https://github.com/dgenio/agent-kernel/security/advisories/new + about: Do not disclose suspected vulnerabilities in a public issue. Use a private GitHub Security Advisory. + - name: Read the Security Contract first + url: https://github.com/dgenio/agent-kernel/blob/main/docs/security-contract.md + about: Check the guarantees and explicit non-goals before reporting a security-boundary mismatch. From 4c62ca0c123aa32cff41b5ad973865f2f3410f2d Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 11 Aug 2026 07:02:20 +0100 Subject: [PATCH 6/9] community: add security-aware pull request template --- .github/pull_request_template.md | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..34f4f03 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,36 @@ +## What changed + + + +## Why + + + +## Security / contract impact + +- [ ] No security/public-contract impact +- [ ] Changes a principal / policy / capability / token / constraint path +- [ ] Changes execution or driver mediation +- [ ] Changes Firewall / Frame / handle behavior +- [ ] Changes audit / trace / evidence behavior +- [ ] Changes protocol/framework compatibility or coverage +- [ ] Changes the Security Contract or a documented non-goal + + + +## Compatibility / migration + + + +## Validation + +- [ ] `make ci` passes on the exact PR head +- [ ] Tests cover the behavior change, including a negative/fail-closed case when security-sensitive +- [ ] I checked whether `tests/test_invariants.py` or `tests/test_policy_properties.py` should change +- [ ] Documentation matches implementation +- [ ] CHANGELOG/release notes are updated when user-visible behavior or compatibility changes +- [ ] New dependency/protocol ranges are justified by tested compatibility, not speculative version widening + +## Review notes + + From f01341eae1c2cc6ee7b2450896606b6a1831aa26 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 11 Aug 2026 07:11:51 +0100 Subject: [PATCH 7/9] docs: keep conduct reporting separate from security advisories --- CODE_OF_CONDUCT.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3e26570..29f0e8f 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -22,22 +22,22 @@ This code applies in repository issues, pull requests, reviews, security advisor ## Reporting conduct concerns -Do not force a reporter to disclose a sensitive conduct concern in a public issue. +Do not disclose sensitive conduct concerns through the vulnerability-reporting channel. GitHub Security Advisories are reserved for security vulnerabilities. -Until the project has a dedicated private conduct mailbox, use the repository's private GitHub advisory channel as the confidential maintainer contact: +The project does not currently publish a dedicated private conduct mailbox. Until one exists: -`https://github.com/dgenio/agent-kernel/security/advisories/new` +- use GitHub's platform **Report abuse / Report content** mechanisms for harassment, threats, doxxing, discrimination, or other concerns that should be handled privately by the platform; +- for non-sensitive project-moderation concerns, a concise public issue/comment may be appropriate when the reporter is comfortable doing so; +- if the concern involves the repository maintainer, prefer GitHub's platform reporting route rather than asking that maintainer to privately adjudicate the complaint. -Prefix the advisory title with **`[Conduct]`** so it is triaged as a conduct report rather than a vulnerability report. Do not include unrelated credentials or sensitive production data. - -If a report concerns the repository maintainer directly and the reporter does not consider that channel appropriate, use GitHub's platform abuse/reporting mechanisms instead. +This limitation is intentional and transparent: the project should not pretend a private conduct channel exists when it does not. A dedicated project contact can replace this section later if one is established. ## Enforcement -Maintainers may edit or remove comments, decline contributions, issue a warning, temporarily restrict participation, or permanently ban participation when behavior materially violates this code. Enforcement should be proportionate, documented privately where appropriate, and avoid disclosing information a reporter expected to remain confidential. +Maintainers may edit or remove comments, decline contributions, issue a warning, temporarily restrict participation, or permanently ban participation when behavior materially violates this code. Enforcement should be proportionate and should avoid amplifying information a reporter expected to remain sensitive. Retaliation against someone for making a good-faith report is itself unacceptable. ## Attribution -This policy follows the principles and enforcement intent of widely used open-source contributor codes of conduct, including the Contributor Covenant, while keeping the project-specific reporting route explicit. +This policy follows the principles and enforcement intent of widely used open-source contributor codes of conduct, including the Contributor Covenant, while keeping the project's current reporting limitations explicit. From 70cb1e6c90247bb1141b3131aa175977c93aad54 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Thu, 20 Aug 2026 06:01:09 +0100 Subject: [PATCH 8/9] docs: make security advisory link directly clickable --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 124dfd2..7846b64 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,7 @@ Use GitHub's private repository-security channel: Direct link when available: -`https://github.com/dgenio/agent-kernel/security/advisories/new` + Include, where possible: From 01a51b558d78eb72396ba22c90d7ec2f2b09a87c Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 21 Aug 2026 06:00:39 +0100 Subject: [PATCH 9/9] chore: refresh community-health branch onto current main --- AGENTS.md | 9 +- CHANGELOG.md | 66 +++ CONTRIBUTING.md | 10 + README.md | 1 + RELEASE.md | 50 ++ docs/adr/0001-token-signing-evolution.md | 111 +++++ docs/agent-context/invariants.md | 10 + docs/architecture.md | 2 +- docs/deployment-consistency.md | 90 ++++ docs/integrations.md | 3 + docs/mcp-safety-classification.md | 76 +++ docs/production-checklist.md | 172 +++++++ docs/security.md | 93 +++- docs/versioning.md | 152 ++++++ pyproject.toml | 2 +- src/weaver_kernel/__init__.py | 6 +- src/weaver_kernel/_hmac_provider.py | 261 ++++++++++ src/weaver_kernel/_secrets.py | 112 ++++- src/weaver_kernel/_token_signing.py | 129 +++++ src/weaver_kernel/cli/_doctor.py | 3 +- src/weaver_kernel/coding_agent.py | 6 +- src/weaver_kernel/coding_agent_demo.py | 2 +- .../default_policy_access_rules.py | 216 +++++++++ .../default_policy_limit_rules.py | 98 ++++ .../default_policy_rule_types.py | 64 +++ src/weaver_kernel/default_policy_rules.py | 112 +++++ src/weaver_kernel/drivers/mcp.py | 38 +- .../drivers/mcp_classification.py | 95 ++++ src/weaver_kernel/errors.py | 12 +- src/weaver_kernel/kernel/__init__.py | 118 +++-- src/weaver_kernel/kernel/_constraints.py | 223 +++++++++ src/weaver_kernel/kernel/_grant.py | 144 ++++++ src/weaver_kernel/otel.py | 3 +- src/weaver_kernel/policy.py | 449 +++--------------- src/weaver_kernel/policy_reasons.py | 6 + src/weaver_kernel/policy_ttl.py | 70 +++ src/weaver_kernel/rate_limit.py | 16 + src/weaver_kernel/tokens.py | 237 ++------- tests/test_architecture.py | 5 +- tests/test_kernel.py | 402 ++++++++++++++++ tests/test_mcp_discovery_safety.py | 145 ++++++ tests/test_mcp_driver.py | 13 +- tests/test_multi_worker_consistency.py | 143 ++++++ tests/test_policy.py | 40 ++ tests/test_policy_properties.py | 7 +- tests/test_policy_rule_chain.py | 261 ++++++++++ tests/test_secrets.py | 56 +++ tests/test_tokens.py | 210 ++++++++ 48 files changed, 3843 insertions(+), 706 deletions(-) create mode 100644 docs/adr/0001-token-signing-evolution.md create mode 100644 docs/deployment-consistency.md create mode 100644 docs/mcp-safety-classification.md create mode 100644 docs/production-checklist.md create mode 100644 docs/versioning.md create mode 100644 src/weaver_kernel/_hmac_provider.py create mode 100644 src/weaver_kernel/_token_signing.py create mode 100644 src/weaver_kernel/default_policy_access_rules.py create mode 100644 src/weaver_kernel/default_policy_limit_rules.py create mode 100644 src/weaver_kernel/default_policy_rule_types.py create mode 100644 src/weaver_kernel/default_policy_rules.py create mode 100644 src/weaver_kernel/drivers/mcp_classification.py create mode 100644 src/weaver_kernel/kernel/_constraints.py create mode 100644 src/weaver_kernel/kernel/_grant.py create mode 100644 src/weaver_kernel/policy_ttl.py create mode 100644 tests/test_mcp_discovery_safety.py create mode 100644 tests/test_multi_worker_consistency.py create mode 100644 tests/test_policy_rule_chain.py create mode 100644 tests/test_secrets.py diff --git a/AGENTS.md b/AGENTS.md index fdb4caf..167800f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,10 +136,11 @@ See [docs/integrations.md](docs/integrations.md) for MCP and HTTP examples. ## Adding a policy rule -1. Add the rule to `DefaultPolicyEngine.evaluate()` in `policy.py`. -2. **Placement matters:** rules are evaluated in order. A new rule placed before sensitivity checks silently bypasses them. -3. If adding a new `SensitivityTag`, you must also add a corresponding policy rule — otherwise the tag is silently ignored. -4. Cover it with a test in `tests/test_policy.py`. +1. Implement the condition in the appropriate `default_policy_*_rules.py` helper. +2. Register it exactly once in the ordered `_DEFAULT_RULES` tuple in `default_policy_rules.py`; both `evaluate()` and `explain()` consume that chain. +3. **Placement matters:** rules are evaluated in order. A new rule placed before sensitivity checks can change which denial short-circuits first. +4. If adding a new `SensitivityTag`, you must also add a corresponding policy rule — otherwise the tag is silently ignored. +5. Cover decision/explanation agreement in `tests/test_policy_rule_chain.py` and behavior in `tests/test_policy.py`. ## Review checklist (beyond `make ci`) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f49854..b2acceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **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. A non-`SafetyClass` key in the `max_ttl_s` map is + rejected at construction so a misconfigured cap can never be silently ignored. + - **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. A malformed rule spec (wrong container + type, non-string `allowed_keys` element, non-string `prefix` value) also fails + closed with the same reason code rather than raising an untyped `TypeError` or + being silently ignored. 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`. A naive (timezone-less) timestamp is + treated as UTC, so an untrusted token can never trigger a naive-vs-aware + `TypeError` at verification time. 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 +- **Default policy decisions and explanations now share one ordered rule chain (#219).** `evaluate()` short-circuits the shared chain while `explain()` collects every failure through the same rules. Rate-limit explanation uses a read-only `peek()` so it can predict a denial without consuming, creating, or pruning limiter state. Agreement and no-mutation regressions cover the security-critical boundary. +- **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`; + import it from `weaver_kernel` (the public export is unchanged). To keep the + token modules import-cycle-free (CodeQL), `weaver_kernel.tokens` no longer + re-exports `HMACTokenProvider`; the logger name is unchanged. + +## [0.12.0] - 2026-08-14 + +### Changed +- **Fail-closed MCP tool discovery (#181).** `MCPDriver.discover()` now rejects + tools that lack both an explicit operator `safety_class_map` and usable MCP + safety hints, rather than silently defaulting to `READ`. A new + `unannotated_safety` parameter provides an explicit opt-in fallback. See + [docs/mcp-safety-classification.md](docs/mcp-safety-classification.md) for + precedence rules and migration guidance. + ### Added - **Coding-agent least privilege (#253).** `CodingAgentPolicyEngine` introduces narrow repository-read/write, local-test, network, secret, PR-create, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecbc105..899ab55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,13 @@ Thank you for your interest in contributing! +Before changing a public or security-sensitive surface, read the +[versioning/stability policy](docs/versioning.md) and the +[Security Contract](docs/security-contract.md). Pre-1.0 does not mean accidental +breaking changes are free: supported changes need an explicit compatibility and +migration story, and unsafe compatibility should fail closed rather than be +preserved silently. + ## Development setup ```bash @@ -52,6 +59,9 @@ These run as ordinary pytest checks (no extra commands): 3. All checks in `make ci` must pass. 4. Follow the existing code style (ruff-enforced). 5. Write docstrings on all public interfaces. +6. Identify whether a change affects public API, a machine/wire contract, an integration compatibility promise, or the Security Contract. +7. For a deliberate breaking change, document the rationale and migration path in the same change/release workflow. +8. Do not preserve a fail-open or invariant-violating behavior merely to avoid a breaking change. ## Security diff --git a/README.md b/README.md index 6772a9e..fa99119 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ The historical GitHub slug is intentionally retained for now. A repository renam - [Security Contract](docs/security-contract.md) - [Security Model](docs/security.md) +- [Production checklist](docs/production-checklist.md) - [Roadmap](ROADMAP.md) - [Tutorial](docs/tutorial.md) - [Architecture](docs/architecture.md) diff --git a/RELEASE.md b/RELEASE.md index 8c279ba..e9703ad 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -89,6 +89,56 @@ python -m pypi_attestations verify pypi \ weaver_kernel--py3-none-any.whl ``` +## Writing release notes + +The `publish.yml` workflow seeds the GitHub Release with **auto-generated notes** +(a flat list of merged PR titles). Those are a starting point, not the finished +product — **rewrite them** before the release is done. + +Release notes are a **decision-support document**, not a commit log. Optimize for +the reader making a decision, not the maintainer looking up a PR. Write for these +readers, in priority order: + +1. **Existing adopters** deciding *"do I upgrade, and will it break me?"* — lead + with breaking changes and migrations. +2. **Security auditors and AI agents** deciding *"is this safe to adopt/bump?"* — + call out security-relevant and behavior-default changes explicitly, so they + can be found without reading prose. +3. **Evaluators** deciding *"is this project alive and going my way?"* — give a + short narrative of the release's theme. +4. **Maintainers / future self** — keep PR links as trailing citations. + +Structure (mirror `CHANGELOG.md`; do not invent a third format): + +- **Highlights** — 2–3 sentences naming the release's theme. +- **⚠️ Breaking changes** *first* — exact symbol + migration for each. +- **🔒 Security** — fail-closed/default changes, redaction, supply-chain; tagged. +- **✨ New features** — grouped by theme, flagship first. +- **🔧 Infrastructure & docs** — collapse Dependabot/CI churn into a line or two; + never let it dominate the notes. +- **🤖 For automated tooling** — an explicit, greppable list of new public + symbols plus a pointer to the breaking-change section. +- **Full Changelog** compare link last. + +Apply the rewrite to the live release with a notes file (avoids shell-escaping): + +```bash +gh release edit v --notes-file +``` + +Non-negotiables for this library: + +- **Breaking-changes-first** and **security-tagged** — fail-closed behavior is + the whole value proposition, so upgrade-safety information leads. +- Keep the notes **consistent with `CHANGELOG.md`**; the two surfaces must not + drift. +- **Bury dependency-bump noise**; surface behavior and API changes. + +Revisit this convention if the audience mix changes — a stable 1.x with many +production adopters would weight migrations even more heavily, while a +pre-adoption phase weights the evaluator narrative. Update this section when the +logic should change. + ## Trusted Publisher Setup Trusted Publisher uses OpenID Connect (OIDC) so the GitHub Actions workflow can diff --git a/docs/adr/0001-token-signing-evolution.md b/docs/adr/0001-token-signing-evolution.md new file mode 100644 index 0000000..3726a92 --- /dev/null +++ b/docs/adr/0001-token-signing-evolution.md @@ -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. diff --git a/docs/agent-context/invariants.md b/docs/agent-context/invariants.md index 4397874..108dffc 100644 --- a/docs/agent-context/invariants.md +++ b/docs/agent-context/invariants.md @@ -86,6 +86,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 diff --git a/docs/architecture.md b/docs/architecture.md index 35668fe..00899aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,7 +99,7 @@ Intent-aware rules fail closed: a request with `intent=None` never matches a rul #### Denial explanations -`PolicyEngine.explain()` (when available) returns a structured `DenialExplanation` with `denied`, `rule_name`, a `failed_conditions: list[FailedCondition]` describing each missing condition with `required`/`actual`/`suggestion`/`reason_code`, a `remediation` list, a human-readable `narrative`, and a top-level `reason_code` (the code of the first failed condition). Engines collect all failing conditions (no short-circuit) so callers get the full picture. For `DeclarativePolicyEngine`, an explicit deny rule that fully matches is reported as the cause; partial-match deny rules are skipped during explanation so the surfaced advice is actionable rather than self-defeating. +`PolicyEngine.explain()` (when available) returns a structured `DenialExplanation` with `denied`, `rule_name`, a `failed_conditions: list[FailedCondition]` describing each missing condition with `required`/`actual`/`suggestion`/`reason_code`, a `remediation` list, a human-readable `narrative`, and a top-level `reason_code` (the code of the first failed condition). Engines collect all failing conditions (no short-circuit) so callers get the full picture. `DefaultPolicyEngine.evaluate()` and `.explain()` are driven by one internal ordered rule chain; evaluation short-circuits and records rate usage, while explanation traverses the same rate-limit rule through a read-only `peek()` that never mutates limiter state. For `DeclarativePolicyEngine`, an explicit deny rule that fully matches is reported as the cause; partial-match deny rules are skipped during explanation so the surfaced advice is actionable rather than self-defeating. #### Reason codes diff --git a/docs/deployment-consistency.md b/docs/deployment-consistency.md new file mode 100644 index 0000000..5e547d6 --- /dev/null +++ b/docs/deployment-consistency.md @@ -0,0 +1,90 @@ +# Deployment consistency model + +Weaver Kernel is an **in-process enforcement runtime**, not a distributed authorization service. Some state is deliberately local to a Kernel/process unless the deployment supplies a shared backend. + +This matters because signed capability tokens are partly stateless while revocation, rate limiting, handles and other runtime state can be local. A multi-worker deployment can therefore have different semantics from a single process even when every worker uses the same signing secret. + +The current behavior is pinned by [`tests/test_multi_worker_consistency.py`](../tests/test_multi_worker_consistency.py). + +## Current matrix + +| Component | Default state | Two workers sharing the same secret | Consequence | +| --- | --- | --- | --- | +| capability-token signature verification | stateless HMAC | token issued by A verifies in B | expected and useful | +| token revocation | in-memory store unless replaced | revoking in A does not revoke in B | revocation is not globally consistent by default | +| rate-limit windows | in-memory | each worker has an independent window | an effective deployment-wide limit can scale with worker count | +| handles / expanded results | in-memory `HandleStore` | handle created in A is unknown in B | requests that move workers cannot expand that handle | +| in-memory traces | process-local | each worker sees its own trace store | audit history fragments unless a shared/durable store is used | +| budget/runtime counters | process-local where backed by in-memory state | counters can diverge | deployment-wide budgets require a shared coordination model | + +## Reproducible evidence + +The test suite demonstrates four load-bearing facts through public/component APIs: + +1. two `HMACTokenProvider` instances with the same secret accept the same valid signed token; +2. revoking that token in worker A does not alter worker B's independent in-memory revocation store; +3. two `RateLimiter` instances have independent windows for the same logical principal/capability key; +4. two `HandleStore` instances do not share handle payloads. + +Run: + +```bash +pytest -q tests/test_multi_worker_consistency.py +``` + +These tests are intentionally documentation-as-code: if the implementation changes, the deployment claim must change with it. + +## Supported deployment guidance today + +### Single process + +A single process gives the clearest semantics for the default in-memory stores. It is the easiest deployment profile to reason about when evaluating the library. + +### Multiple workers with only a shared signing secret + +Do **not** interpret a shared `WEAVER_KERNEL_SECRET` as shared authorization state. It lets workers verify the same token signatures; it does not by itself synchronize revocation, limits, handles or traces. + +If a security requirement depends on immediate global revocation, one deployment-wide rate limit, portable handles or one authoritative audit history, the default independent in-memory stores are insufficient. + +### Shared/durable stores + +Use an available shared/durable backend where one exists and validate its consistency properties for the deployment. A durable backend solves only the state it actually owns; it should not be described as making every Kernel subsystem distributed automatically. + +For example, sharing trace storage does not automatically share rate-limit windows or handles. + +## Architectural decision before a sidecar + +The existence of process-local state does **not** by itself justify building a remote Kernel service. + +The sequence should be: + +1. identify which guarantees real adopters need across workers; +2. determine whether a small shared-store protocol is sufficient; +3. measure the latency/failure/operational cost of shared state; +4. use a sidecar/remote Kernel only if it materially simplifies the required consistency or trust boundary. + +This is why the remote-mode proposal (#227) is intentionally lower priority than documenting and validating this consistency model. + +## Security claim language + +Prefer: + +> “With the default in-memory stores, revocation, rate limits and handles are process-local. Signed tokens can verify across workers that share the signing secret.” + +Avoid: + +> “Workers share Kernel authorization state because they use the same secret.” + +Also avoid describing Kernel as a distributed policy service unless the deployed backends and topology actually establish those semantics. + +## Follow-up decisions + +The evidence here should inform, rather than pre-decide: + +- whether revocation needs a first-class shared-store recommendation; +- whether invocation limits need deployment-wide state after #170/PR #259 settles their semantics; +- whether handles should ever be portable across workers or should remain intentionally sticky/local; +- whether audit stores need a recommended production backend; +- whether #227 earns its complexity from actual adopter requirements. + +See the [Security Contract](security-contract.md) and [Roadmap](../ROADMAP.md) for the broader product/security gates. diff --git a/docs/integrations.md b/docs/integrations.md index bb31b51..2274b44 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -86,6 +86,9 @@ asyncio.run(main()) ### Notes - `discover()` converts `tools/list` results into `Capability` objects. +- Safety classification of discovered tools is documented in + [MCP safety classification](mcp-safety-classification.md). Unannotated tools + are rejected by default; use `safety_class_map` or opt in to a fallback class. - `execute()` calls `tools/call` and normalizes MCP content blocks for the firewall. - MCP `isError` responses raise `DriverError` with the server-provided detail. - If `mcp` is not installed, factory methods raise a helpful `ImportError`. diff --git a/docs/mcp-safety-classification.md b/docs/mcp-safety-classification.md new file mode 100644 index 0000000..77ff83f --- /dev/null +++ b/docs/mcp-safety-classification.md @@ -0,0 +1,76 @@ +# MCP tool safety classification + +`MCPDriver.discover()` treats MCP tool annotations as **advisory metadata**, not as trusted authorization input. + +## Default: reject missing safety metadata + +A tool that has neither: + +- an explicit operator classification in `safety_class_map`, nor +- a useful MCP `readOnlyHint` / `destructiveHint` + +is rejected by default rather than silently becoming `SafetyClass.READ`. + +```python +from weaver_kernel import SafetyClass + +capabilities = await driver.discover( + namespace="github", + safety_class_map={ + "list_issues": SafetyClass.READ, + "create_issue": SafetyClass.WRITE, + "delete_repository": SafetyClass.DESTRUCTIVE, + }, +) +``` + +If any discovered tool remains unclassified, discovery raises `DriverError` and names every affected tool. This makes missing metadata visible before the capabilities are registered or invoked. + +## Explicit fallback + +For a controlled environment where an operator deliberately wants one fallback class, opt in explicitly: + +```python +capabilities = await driver.discover( + unannotated_safety=SafetyClass.WRITE, +) +``` + +Kernel logs a warning naming each tool that received the fallback. Choosing a fallback is an operator decision; it is never inferred from the absence of metadata. + +To preserve the previous pre-#181 behavior deliberately, an adopter may pass: + +```python +capabilities = await driver.discover( + unannotated_safety=SafetyClass.READ, +) +``` + +That opt-back should be reviewed carefully: `READ` is the least-restricted default safety class and an MCP server can expose tools whose names or descriptions understate their side effects. + +## Precedence + +Classification uses this order: + +1. explicit `safety_class_map[tool_name]` supplied by the operator; +2. `destructiveHint=True` → `DESTRUCTIVE`; +3. `readOnlyHint=True` → `READ`; +4. explicit `unannotated_safety` fallback, if configured; +5. otherwise reject. + +If a server supplies conflicting read-only and destructive hints, destructive wins. + +## Security boundary + +This change prevents **missing MCP metadata** from silently granting READ-level authority. It does not make MCP annotations trustworthy. A compromised or incorrect server can still mislabel a destructive tool as read-only. + +For high-assurance deployments, treat the operator-maintained classification map (or an equivalent reviewed policy artifact) as the authoritative safety classification. Combine classification with normal principal/capability policy, token constraints and the execution/audit boundary described in [`security.md`](security.md). + +## Migration from earlier releases + +Earlier versions inferred `READ` when annotations were absent. Code that relied on that behavior must now choose one of two explicit migrations: + +- **recommended:** classify tools by name with `safety_class_map`; +- **compatibility opt-back:** set `unannotated_safety=SafetyClass.READ` and accept the warning/audit implications. + +This is a deliberate fail-closed breaking change for a security-sensitive default. diff --git a/docs/production-checklist.md b/docs/production-checklist.md new file mode 100644 index 0000000..28701a1 --- /dev/null +++ b/docs/production-checklist.md @@ -0,0 +1,172 @@ +# Production checklist + +Weaver Kernel is pre-1.0 security infrastructure. Treat production readiness as a set of explicit guarantees and deployment choices, not as a single configuration flag. + +Start with the [Security Contract](security-contract.md). This checklist covers the operator decisions most likely to invalidate that contract when a development setup becomes a real deployment. + +## 1. Establish authenticated principal identity + +Kernel authorizes a `Principal`; it does not authenticate that principal for you. + +- derive principal identity from an authentication/workload-identity mechanism you trust; +- do not let model-provided text choose `principal_id` or privileged roles directly; +- document whether the principal represents a human user, workload/service, agent instance, or delegated identity; +- test that authentication failure prevents a capability grant from being minted. + +Production authentication/provider hardening is tracked in #103/#279. + +## 2. Set and protect the HMAC signing secret + +Set a strong `WEAVER_KERNEL_SECRET` before production use. + +The development fallback is intentionally process-local and random. It is useful for examples because it requires no setup, but: + +- tokens signed with it become invalid after restart; +- separate processes generate different secrets; +- local audit-chain signatures use the same secret-resolution path; +- it is not a substitute for managed secret distribution/rotation. + +Do not store sensitive payloads inside capability tokens: HMAC provides integrity/authenticity inside the shared-secret trust domain, **not encryption**. + +Key rotation and token-lifecycle hardening are tracked in #185 / PR #259. + +## 3. Review capability and tool classification + +Unknown authority should fail closed. + +For MCP and other discovered tool surfaces: + +- maintain an operator-reviewed mapping for high-risk tools; +- treat server/framework safety metadata as advisory rather than authoritative; +- verify WRITE/DESTRUCTIVE classifications against actual side effects; +- include paths/resources/destinations in constraints where the action needs narrower authority than the tool name alone expresses. + +#181 / PR #277 hardens the MCP default so missing metadata does not silently become READ. + +## 4. Test policy with both allow and deny cases + +Before deploying a policy: + +- test actions that should succeed; +- test adjacent actions that must fail; +- test a different principal attempting to reuse authority; +- test malformed/oversized constraints; +- test approval/escalation paths without granting adjacent authority; +- prefer stable reason codes over parsing human-readable messages. + +Run the named invariant suite as part of normal CI: + +```bash +pytest -q tests/test_invariants.py +``` + +Your application should also have integration tests for its own principal/capability/resource semantics. + +## 5. Decide token TTL, reuse and revocation semantics deliberately + +A long-lived reusable grant is more authority than a short, single-purpose one. + +Review: + +- token TTL by safety class/use case; +- whether a token should be reusable or single/max-use; +- invoke-time rate limits; +- revocation expectations; +- what must happen when constraints are malformed or cannot be enforced. + +Current lifecycle hardening is concentrated in #170/#185 and PR #259. Do not claim stronger invocation-limit or rotation guarantees until the deployed release actually contains that work. + +## 6. Verify every security-sensitive execution path is mediated + +Kernel protects actions that pass through its enforcement path. It cannot stop a host/framework from calling the underlying tool around it. + +For each integration, make a coverage table: + +| Execution surface | Goes through Kernel? | Alternative boundary | +| --- | --- | --- | +| Custom function/tool call | yes/no | wrapper/gateway/sandbox | +| Hosted/provider tool | yes/no | provider controls | +| Shell/subprocess | yes/no | execution sandbox/policy | +| MCP call | yes/no | Kernel/AgentFence/gateway | +| Handoff/sub-agent path | yes/no | explicit integration | + +Do not publish “framework X is secured by Kernel” unless every relevant execution surface is actually mediated. + +## 7. Understand multi-worker consistency before scaling + +Sharing `WEAVER_KERNEL_SECRET` lets workers verify the same HMAC signatures; it does **not** automatically share all enforcement state. + +Deployment-consistency guidance is still being formalized. In the current design, process-local state can include revocation, rate-limit windows, handles, budgets and traces unless an appropriate shared backend owns that state. + +If your guarantee requires immediate deployment-wide revocation or one global rate limit, prove that the backing architecture provides it before adding workers. + +## 8. Configure audit storage and retention + +Decide what evidence you need after an incident or policy review: + +- where `ActionTrace` records are stored; +- retention period; +- access control for traces; +- whether the store is local, shared, append-only, backed up, or externally anchored; +- what fields are safe to retain. + +Hash chaining can make mutation/reordering evident within its trust assumptions. It is not automatically non-repudiation, and deleting an unanchored local store remains possible. + +Never turn audit into a secret-exfiltration path by storing raw credentials/tool results unnecessarily. + +## 9. Treat redaction as defense in depth + +The Context Firewall structurally bounds result size/shape and applies redaction, but built-in secret/PII detection is heuristic. + +- minimize sensitive data before it reaches an agent tool when possible; +- use field/resource constraints rather than relying only on post-hoc regex redaction; +- test your domain-specific sensitive data with synthetic canaries; +- do not describe the built-in redactor as a complete DLP/data-governance system. + +## 10. Pin and test protocol/integration versions + +A dependency specifier is not proof of compatibility. + +For every optional integration you deploy: + +- use a version range that the project actually tests; +- pin/lock at the application layer according to your release practice; +- run integration tests before dependency upgrades; +- review major protocol/SDK migrations separately from routine dependency updates. + +For MCP specifically, current v1 support and the v2 migration are tracked in #263/#173. Do not blindly widen the MCP major range. + +## 11. Run software-supply-chain checks + +At minimum: + +- run `make ci` on the exact commit/release you deploy; +- review dependency-audit and CodeQL results; +- use the released package/SBOM/attestation workflow described in `RELEASE.md` where applicable; +- avoid adding optional integrations to the base runtime unless they are genuinely required. + +## 12. Establish operational failure behavior + +Decide how the host responds when Kernel cannot safely decide or execute: + +- policy provider timeout/error; +- expired/revoked/invalid token; +- tool/driver timeout; +- malformed constraints; +- audit-store failure; +- rate-limit/budget exhaustion; +- approval broker unavailable; +- protocol incompatibility. + +Security-sensitive uncertainty should normally fail closed. A fallback that widens authority requires explicit review, not an implicit exception handler. + +## 13. Re-read the claims before launch + +Before describing the deployment externally, compare your architecture to: + +- [Security Contract](security-contract.md); +- [Security Model](security.md); +- [Roadmap](../ROADMAP.md); +- the exact released version's CHANGELOG/release notes. + +If your deployment adds a stronger boundary (for example an external gateway/sandbox or shared authoritative store), document that as a property of **your deployment**, not as an unconditional Kernel guarantee. diff --git a/docs/security.md b/docs/security.md index 9b9197e..3974b3c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -26,8 +26,92 @@ A current `CapabilityToken` binds: - `capability_id` — which capability is authorized; - `principal_id` — the authorization subject; -- `constraints` — signed scope such as row/field limits where used; -- `expires_at` — the validity window. +- `constraints` — signed scope such as row/field limits and `args` where used; +- `expires_at` — the validity window; +- `key_id` — which signing key produced the signature (for rotation, below). + +### 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 Changing a signed field invalidates the HMAC signature. @@ -86,9 +170,10 @@ Streaming redaction also has a bounded overlap/memory trade-off: sufficiently pa MCP tool annotations are hints, not a trusted authorization statement. -The current backlog contains a security-critical hardening item (#181) because unannotated tools must not silently receive the least-restricted safety classification on an advertised least-privilege path. +Explicit operator mappings (`safety_class_map`) take precedence over server-provided hints. `destructiveHint=True` wins over `readOnlyHint=True` when both are present. Tools with neither usable hints nor an explicit mapping are **rejected by default** (#181) rather than silently becoming `READ`. -Until that item is resolved, production integrations should supply an explicit `safety_class_map` for discovered tools rather than trusting the default fallback. +For full precedence rules and migration from pre-#181 behavior, see +[mcp-safety-classification.md](mcp-safety-classification.md). MCP SDK/protocol compatibility is also a live support boundary (#263, #173). Check the supported dependency range before relying on the integration. diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..ddbfe5f --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,152 @@ +# Versioning, stability and deprecation policy + +Weaver Kernel uses Semantic Versioning as a communication framework, with an explicit **pre-1.0 stability policy** because the library is still refining its security and integration contracts. + +The goal is not to promise that `0.x` never changes. The goal is to make every supported change predictable enough that downstream teams can decide when and how to upgrade. + +## What is the public API? + +The supported Python API is the surface intentionally exported through `weaver_kernel.__all__`, plus documented public submodules that are explicitly described as supported. + +The repository pins the top-level export contract in `tests/test_public_api.py` and the public docstring contract in `tests/test_docstrings.py`. + +The following are **not** stable API unless separately documented: + +- names beginning with `_`; +- implementation modules reached through deep imports only; +- test fixtures/internal helpers; +- example-only code; +- experimental APIs whose docs explicitly say they may change; +- serialized/wire formats that do not have a published versioned schema or conformance contract. + +A downstream import working today does not by itself make that import public API. + +## Semantic-version interpretation before 1.0 + +### Patch release: `0.x.y → 0.x.(y+1)` + +Patch releases should be compatible bug fixes, security fixes, documentation corrections and implementation changes that preserve the supported public contract. + +A security fix may deliberately make previously permissive or undefined behavior fail closed. If that affects a supported usage pattern, the release notes must call it out prominently even when the public Python signature is unchanged. + +### Minor release: `0.x.y → 0.(x+1).0` + +Minor releases may add features and may contain **deliberate breaking changes** while the project is pre-1.0, but breaking changes are not license for surprise churn. + +A planned breaking change must have: + +1. a written rationale tied to security, correctness, interoperability or a validated product need; +2. a migration path where technically possible; +3. a CHANGELOG/release-note entry identifying the break; +4. compatibility/deprecation handling when keeping the old behavior temporarily does not weaken security; +5. tests that pin the intended new contract. + +Minor releases should not bundle unrelated breaking changes merely because “0.x allows it.” + +### Major release: `0.x.y → 1.0.0` + +`1.0.0` means the project is prepared to treat its documented supported surfaces as a long-lived compatibility contract. It does **not** mean that every experimental idea in the backlog is complete. + +## Deprecation mechanics + +When an old API can remain safely available: + +1. introduce the replacement; +2. keep the old name/behavior as an alias or compatibility path; +3. document the replacement in the CHANGELOG and migration notes; +4. emit `DeprecationWarning` where doing so is reliable and useful; +5. keep the compatibility path for at least **two minor releases** unless a security or correctness issue requires faster removal; +6. remove it in a later minor release before 1.0, or in a major release after 1.0. + +The `agent_kernel` → `weaver_kernel` naming transition and compatibility-alias work are the model: migrate deliberately rather than carrying ambiguous names forever or deleting them without warning. + +## When we may skip a deprecation window + +A deprecation window is not required when preserving the old behavior would itself be unsafe or misleading. + +Examples: + +- a fail-open security default; +- accepting malformed signed security configuration; +- silently granting authority from missing metadata; +- behavior that violates a documented invariant; +- compatibility with a protocol version that cannot be implemented correctly without weakening validation. + +In these cases the project should prefer a **fail-closed breaking fix**, but must still provide: + +- prominent release notes; +- explicit migration instructions where possible; +- regression tests; +- a documented compatibility opt-back only when the opt-back is itself safe enough to offer deliberately. + +Security should not be weakened to preserve accidental compatibility. + +## Stable reason codes and machine contracts + +Machine-consumed reason codes, exported schemas and versioned evidence/token contracts deserve stricter treatment than prose error messages. + +When a reason code is documented as stable: + +- do not repurpose it to mean a different condition; +- add a new code for a new condition; +- keep adapters/tests branching on the code rather than parsing human-readable messages. + +When a wire artifact becomes cross-language or independently implemented, it must have an explicit schema/version and conformance fixtures before it is described as stable. + +## Integration compatibility + +Framework/protocol support is versioned as part of the product contract. + +For an advertised integration, documentation should state: + +- dependency/SDK versions tested in CI; +- which execution surfaces are actually mediated; +- known unsupported/bypass surfaces; +- whether support is stable, provisional or experimental. + +A dependency range in `pyproject.toml` should reflect what CI proves, not what dependency resolution happens to accept. + +Major dependency transitions such as MCP SDK v1 → v2 therefore require explicit migration/interoperability work rather than a blind version-range widening. + +## Security-contract changes + +[`security-contract.md`](security-contract.md) is a compatibility surface too. + +A release that narrows or broadens a security claim must update: + +- the contract; +- executable invariant/security tests; +- affected integration coverage matrices; +- release notes. + +Broadening a claim requires evidence. Narrowing a claim because implementation reality was previously overstated is considered a correctness fix and should happen immediately. + +## Criteria for 1.0 + +The project can consider `1.0.0` when all of the following are true: + +- [ ] the security contract has remained coherent across multiple releases and maps to executable tests; +- [ ] no known fail-open issue remains on the advertised supported paths; +- [ ] identity/principal provenance and deployment-consistency assumptions are documented for production profiles; +- [ ] supported framework/protocol version envelopes are continuously tested; +- [ ] the public API definition is mechanically pinned and the deprecation policy has been followed in practice for at least two minor releases; +- [ ] externally reviewed threat-model findings have been incorporated into the supported contract; +- [ ] the project has genuine independent downstream use, not only maintainer-authored demos; +- [ ] any implementation-neutral Weaver contracts claimed as stable have conformance fixtures and at least one independent consumer/implementation where the claim depends on interoperability; +- [ ] release/package/docs drift is controlled so a released user sees the behavior the current documentation promises. + +These are readiness criteria, not a date commitment. + +## Change checklist for contributors + +Before changing a public or security-sensitive surface, ask: + +1. Is this public API, a machine contract, a security contract, or an internal detail? +2. Does the change break a documented supported behavior? +3. Can a compatibility path exist without weakening security/correctness? +4. What exact migration does a downstream adopter need? +5. Which tests make the old/new contract visible? +6. Does the CHANGELOG/release note need a `Changed`, `Deprecated`, `Removed` or security note? +7. Does an integration compatibility matrix or `security-contract.md` need to change? + +If the answers are unclear, treat the change as potentially breaking until reviewed. diff --git a/pyproject.toml b/pyproject.toml index 17d75c2..d5d96b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "weaver-kernel" -version = "0.11.0" +version = "0.12.0" description = "Execution enforcement and audit for AI-agent actions" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/weaver_kernel/__init__.py b/src/weaver_kernel/__init__.py index 94a3b39..a86ed72 100644 --- a/src/weaver_kernel/__init__.py +++ b/src/weaver_kernel/__init__.py @@ -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, @@ -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 @@ -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, diff --git a/src/weaver_kernel/_hmac_provider.py b/src/weaver_kernel/_hmac_provider.py new file mode 100644 index 0000000..d21daf8 --- /dev/null +++ b/src/weaver_kernel/_hmac_provider.py @@ -0,0 +1,261 @@ +"""The :class:`HMACTokenProvider` implementation. + +Extracted from :mod:`weaver_kernel.tokens` to keep that module within the +AGENTS.md 300-line budget. :class:`~weaver_kernel.tokens.CapabilityToken` and +the :class:`~weaver_kernel.tokens.TokenProvider` Protocol remain in +:mod:`weaver_kernel.tokens`. This module imports from ``tokens`` (a one-way +dependency), so ``tokens`` does *not* re-export this class — that would form an +import cycle. Import it from :mod:`weaver_kernel` (public) instead. +""" + +from __future__ import annotations + +import datetime +import hmac +import logging +import uuid +from typing import Any + +from ._secrets import resolve_keyring +from ._token_signing import KeyRing, sign +from .errors import AgentKernelError, TokenExpired, TokenInvalid, TokenRevoked, TokenScopeError +from .stores import InMemoryRevocationStore, RevocationStoreProtocol +from .tokens import CapabilityToken + +# Keep the logger name stable across the tokens.py → _hmac_provider.py split so +# operators (and tests) filtering on "weaver_kernel.tokens" still see these records. +logger = logging.getLogger("weaver_kernel.tokens") + + +class HMACTokenProvider: + """Issues and verifies HMAC-SHA256 capability tokens. + + Supports signing-key rotation (#185): pass a ``secrets`` map of + ``{key_id: secret}`` plus an ``active_key_id`` to sign new tokens under one + key while still verifying tokens signed under others during an overlap + window. A single ``secret`` (or the ``WEAVER_KERNEL_SECRET`` env var) is + filed under the ``"default"`` key id. When nothing is configured the + ``WEAVER_KERNEL_SECRETS`` / ``WEAVER_KERNEL_SECRET`` env vars are consulted, + falling back to a random development secret with a one-time warning. + + Args: + secret: A single signing secret. Mutually exclusive with *secrets*. + secrets: A ``{key_id: secret}`` key-ring for rotation. + active_key_id: Which *secrets* key to sign new tokens with. Required when + *secrets* holds more than one key; inferred when it holds exactly one. + revocation_store: Backing store for revocation state; defaults to an + in-memory store. + + Raises: + AgentKernelError: If both *secret* and *secrets* are given, or an + explicit key-ring is empty, malformed, or names an unknown + *active_key_id*. + """ + + def __init__( + self, + secret: str | None = None, + *, + secrets: dict[str, str] | None = None, + active_key_id: str | None = None, + revocation_store: RevocationStoreProtocol | None = None, + ) -> None: + if secret is not None and secrets is not None: + raise AgentKernelError( + "HMACTokenProvider: pass either 'secret' or 'secrets', not both." + ) + self._secret = secret + self._secrets = secrets + self._active_key_id_arg = active_key_id + # Explicit config is validated eagerly; the env/dev-fallback path stays + # lazy so the dev-secret warning fires only on first use, not import. + self._keyring: KeyRing | None = None + self._active_key_id: str = "" + if secret is not None or secrets is not None: + self._keyring, self._active_key_id = resolve_keyring(secret, secrets, active_key_id) + # 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 _resolve_keyring(self) -> tuple[KeyRing, str]: + """Return the ``(keyring, active_key_id)`` pair, resolving env/dev lazily.""" + if self._keyring is None: + self._keyring, self._active_key_id = resolve_keyring( + self._secret, self._secrets, self._active_key_id_arg + ) + return self._keyring, self._active_key_id + + def issue( + self, + capability_id: str, + principal_id: str, + *, + constraints: dict[str, Any] | None = None, + ttl_seconds: int = 3600, + audit_id: str = "", + ) -> CapabilityToken: + """Issue a new signed token. + + Args: + capability_id: The capability this token authorises. + principal_id: The principal this token is issued to. + constraints: Optional execution constraints. + ttl_seconds: How long the token is valid (default 1 hour). + audit_id: Audit trail ID to embed in the token. + + Returns: + A freshly signed :class:`CapabilityToken`. + """ + keyring, active_key_id = self._resolve_keyring() + now = datetime.datetime.now(tz=datetime.timezone.utc) + token = CapabilityToken( + token_id=str(uuid.uuid4()), + capability_id=capability_id, + principal_id=principal_id, + issued_at=now, + expires_at=now + datetime.timedelta(seconds=ttl_seconds), + constraints=constraints or {}, + audit_id=audit_id, + key_id=active_key_id, + ) + token.signature = sign(keyring[active_key_id], token._signable_payload()) + self._revocation.track(principal_id, token.token_id, token.expires_at) + logger.debug( + "token_issued", + extra={ + "token_id": token.token_id, + "capability_id": capability_id, + "principal_id": principal_id, + "audit_id": audit_id, + "expires_at": token.expires_at.isoformat(), + }, + ) + return token + + def revoke(self, token_id: str) -> None: + """Revoke a single token by ID. + + Idempotent — revoking an already-revoked or unknown token is a no-op. + + Args: + token_id: The ID of the token to revoke. + """ + self._revocation.revoke(token_id) + + def revoke_all(self, principal_id: str) -> int: + """Revoke all tokens issued to a principal. + + Args: + principal_id: The principal whose tokens should be revoked. + + Returns: + The number of tokens newly revoked by this call (excluding tokens + that were already revoked). + """ + return self._revocation.revoke_principal(principal_id) + + def sweep_revocations(self, now: datetime.datetime | None = None) -> int: + """Drop revocation bookkeeping for tokens that have already expired. + + Bounds revocation-state growth in long-lived processes (#182). Safe to + call at any time: an expired token fails the verifier's expiry check + regardless, so sweeping its entry never un-revokes a live token. The + in-memory store also sweeps itself lazily; durable backends expose this + for an operator to call on a schedule. + + Args: + now: Reference time; defaults to the current UTC time. + + Returns: + The number of tracked tokens whose state was removed. + """ + when = now or datetime.datetime.now(tz=datetime.timezone.utc) + return self._revocation.sweep_expired(when) + + def verify( + self, + token: CapabilityToken, + *, + expected_principal_id: str, + expected_capability_id: str, + ) -> None: + """Verify a token's signature, expiry, and scope bindings. + + Args: + token: The token to verify. + expected_principal_id: The principal that should own this token. + expected_capability_id: The capability this token should authorize. + + 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, or the token + declares an unknown signing key id. + 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 (rotation-aware): select the secret for the token's + # declared key id. An unknown key id fails closed — never fall through + # to another key. + keyring, active_key_id = self._resolve_keyring() + secret = keyring.get(token.key_id) + if secret is None: + self._log_verify_failure(token.token_id, "unknown_key_id", key_id=token.key_id) + raise TokenInvalid( + f"Token '{token.token_id}' was signed with unknown key id '{token.key_id}'." + ) + expected_sig = sign(secret, 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." + ) + if token.key_id != active_key_id: + # Never logs the secret — only the key id — so operators can tell + # when the previous key is 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: + 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}'." + ) + + +__all__ = ["HMACTokenProvider"] diff --git a/src/weaver_kernel/_secrets.py b/src/weaver_kernel/_secrets.py index 3a81ca7..d53d8ac 100644 --- a/src/weaver_kernel/_secrets.py +++ b/src/weaver_kernel/_secrets.py @@ -11,16 +11,31 @@ from __future__ import annotations +import json import logging import os import secrets import threading +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.""" +PRODUCTION_CHECKLIST_PATH = "docs/production-checklist.md" +"""Repository-relative operator guidance referenced by the development warning.""" + +SECRETS_ENV_VAR = "WEAVER_KERNEL_SECRETS" +"""Environment variable holding a JSON ``{key_id: secret}`` map for key rotation (#185).""" + +ACTIVE_KEY_ENV_VAR = "WEAVER_KERNEL_ACTIVE_KEY" +"""Environment variable naming which :data:`SECRETS_ENV_VAR` key to sign new tokens with.""" + +LEGACY_KEY_ID = "default" +"""Key id assigned to a single-secret configuration (``secret=`` or ``WEAVER_KERNEL_SECRET``).""" + _DEV_SECRET: str | None = None _DEV_SECRET_LOCK = threading.Lock() @@ -39,10 +54,13 @@ def _get_secret() -> str: if _DEV_SECRET is None: _DEV_SECRET = secrets.token_hex(32) logger.warning( - "%s is not set. Using a random development secret — tokens and " - "audit-chain signatures will not survive restarts. Set %s in production.", + "%s is not set. Using a process-local random development secret; " + "tokens and audit-chain signatures will be invalid after restart and " + "will not share signing state with another process. Set %s before " + "production. Production checklist: %s", SECRET_ENV_VAR, SECRET_ENV_VAR, + PRODUCTION_CHECKLIST_PATH, ) return _DEV_SECRET @@ -62,3 +80,93 @@ def resolve_hmac_secret(explicit: str | None = None) -> str: if explicit: return explicit return _get_secret() + + +def _assemble_keyring( + secrets_map: dict[str, str], + active_key_id: str | None, + *, + source: str, +) -> tuple[dict[str, str], str]: + """Validate a key id → secret map and resolve which key is active. + + Args: + secrets_map: Candidate ``{key_id: secret}`` mapping. + active_key_id: The key id new tokens should be signed with, or ``None`` + to infer it (only possible when the map holds exactly one key). + source: Human-readable origin used in error messages. + + Returns: + A ``(keyring, active_key_id)`` pair with a validated, non-empty keyring. + + Raises: + AgentKernelError: If the map is empty, holds non-string keys/values, has + multiple keys without an explicit active key, or names an active key + id absent from the map. + """ + if not secrets_map: + raise AgentKernelError(f"{source} keyring is empty; at least one key is required.") + if any(not isinstance(k, str) or not isinstance(v, str) for k, v in secrets_map.items()): + raise AgentKernelError(f"{source} keyring must map string key ids to string secrets.") + keyring = dict(secrets_map) + if active_key_id is None: + if len(keyring) == 1: + active_key_id = next(iter(keyring)) + else: + raise AgentKernelError( + f"{source} has multiple keys; an active key id must be specified " + f"(via active_key_id or {ACTIVE_KEY_ENV_VAR})." + ) + if active_key_id not in keyring: + raise AgentKernelError( + f"active key id {active_key_id!r} is not present in the {source} keyring." + ) + return keyring, active_key_id + + +def resolve_keyring( + explicit_secret: str | None, + explicit_secrets: dict[str, str] | None, + explicit_active_key_id: str | None, +) -> tuple[dict[str, str], str]: + """Resolve the signing key-ring and active key id for token rotation (#185). + + Precedence: an explicit ``secrets`` map, then an explicit single ``secret``, + then :data:`SECRETS_ENV_VAR` (JSON ``{key_id: secret}``), then the legacy + single :data:`SECRET_ENV_VAR`, then a generated dev secret (with a one-time + warning). A single secret is filed under :data:`LEGACY_KEY_ID`. + + Args: + explicit_secret: A single secret passed to the provider, or ``None``. + explicit_secrets: A ``{key_id: secret}`` map passed to the provider, or + ``None``. + explicit_active_key_id: The active key id passed to the provider, or + ``None`` to infer it. + + Returns: + A ``(keyring, active_key_id)`` pair. + + Raises: + AgentKernelError: If the resolved configuration is empty, malformed, or + names an unknown active key id. + """ + if explicit_secrets is not None: + return _assemble_keyring(explicit_secrets, explicit_active_key_id, source="secrets=") + if explicit_secret is not None: + return {LEGACY_KEY_ID: explicit_secret}, LEGACY_KEY_ID + env_secrets = os.environ.get(SECRETS_ENV_VAR) + if env_secrets: + try: + parsed = json.loads(env_secrets) + except json.JSONDecodeError as exc: + raise AgentKernelError(f"{SECRETS_ENV_VAR} is not valid JSON: {exc}.") from exc + if not isinstance(parsed, dict): + raise AgentKernelError( + f"{SECRETS_ENV_VAR} must be a JSON object of {{key_id: secret}} strings." + ) + active = explicit_active_key_id or os.environ.get(ACTIVE_KEY_ENV_VAR) + return _assemble_keyring(parsed, active, source=SECRETS_ENV_VAR) + single = os.environ.get(SECRET_ENV_VAR) + if single: + return {LEGACY_KEY_ID: single}, LEGACY_KEY_ID + return {LEGACY_KEY_ID: _get_secret()}, LEGACY_KEY_ID diff --git a/src/weaver_kernel/_token_signing.py b/src/weaver_kernel/_token_signing.py new file mode 100644 index 0000000..2caf5b3 --- /dev/null +++ b/src/weaver_kernel/_token_signing.py @@ -0,0 +1,129 @@ +"""HMAC signing and token-parsing helpers. + +Extracted from :mod:`weaver_kernel.tokens` to keep that module within the +AGENTS.md 300-line budget and to isolate the crypto/deserialization concern: +HMAC signing, and parsing an untrusted serialized token into validated +constructor kwargs — raising :class:`TokenInvalid` rather than leaking a bare +``KeyError``/``ValueError`` (#200). + +This module is a leaf: it imports nothing from :mod:`weaver_kernel.tokens` (not +even under ``TYPE_CHECKING``), so the ``tokens`` → ``_token_signing`` dependency +stays acyclic. Canonical payload building lives on +:meth:`CapabilityToken._signable_payload` in ``tokens.py``. +""" + +from __future__ import annotations + +import datetime +import hashlib +import hmac +from typing import Any + +from .errors import TokenInvalid + +KeyRing = dict[str, str] +"""Mapping of key id → HMAC secret used for signing-key rotation (#185).""" + + +def sign(secret: str, payload: str) -> str: + """Return the hex HMAC-SHA256 of *payload* under *secret*. + + Args: + secret: The signing secret (never logged). + payload: The canonical signable payload string. + + Returns: + The hex-encoded signature. + """ + return hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest() + + +def _require_str(data: dict[str, Any], field: str) -> str: + """Return a required string field, raising :class:`TokenInvalid` otherwise.""" + if field not in data: + raise TokenInvalid(f"malformed token payload: missing field '{field}'.") + value = data[field] + if not isinstance(value, str): + raise TokenInvalid( + f"malformed token payload: field '{field}' must be a string, " + f"got {type(value).__name__}." + ) + return value + + +def _optional_str(data: dict[str, Any], field: str) -> str: + """Return an optional string field (default ``""``), validating its type.""" + value = data.get(field, "") + if not isinstance(value, str): + raise TokenInvalid( + f"malformed token payload: field '{field}' must be a string, " + f"got {type(value).__name__}." + ) + return value + + +def _require_timestamp(data: dict[str, Any], field: str) -> datetime.datetime: + """Return a required ISO-8601 timestamp field, raising :class:`TokenInvalid`. + + A naive (timezone-less) timestamp is treated as UTC — matching how the + revocation stores handle naive datetimes — so a malformed/untrusted token can + never turn into a naive-vs-aware ``TypeError`` at ``verify()`` time. + """ + if field not in data: + raise TokenInvalid(f"malformed token payload: missing field '{field}'.") + raw = data[field] + if not isinstance(raw, str): + raise TokenInvalid( + f"malformed token payload: field '{field}' must be an ISO-8601 string, " + f"got {type(raw).__name__}." + ) + try: + parsed = datetime.datetime.fromisoformat(raw) + except ValueError as exc: + raise TokenInvalid( + f"malformed token payload: invalid timestamp in field '{field}': {raw!r}." + ) from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + return parsed + + +def parse_token_fields(data: dict[str, Any]) -> dict[str, Any]: + """Validate a serialized token dict into constructor kwargs (#200). + + Tokens cross process boundaries by design, so a malformed dict is an + expected input class, not a programming error. Every failure raises + :class:`TokenInvalid` with a stable, descriptive message. Unknown extra + keys are tolerated; ``constraints`` defaults to ``{}`` and must be an object. + + Args: + data: The plain dict produced by :meth:`CapabilityToken.to_dict` (or an + untrusted equivalent). + + Returns: + Keyword arguments suitable for the :class:`CapabilityToken` constructor. + + Raises: + TokenInvalid: If any field is missing, of the wrong type, or a + malformed timestamp. + """ + constraints = data.get("constraints", {}) + if not isinstance(constraints, dict): + raise TokenInvalid( + f"malformed token payload: field 'constraints' must be an object, " + f"got {type(constraints).__name__}." + ) + return { + "token_id": _require_str(data, "token_id"), + "capability_id": _require_str(data, "capability_id"), + "principal_id": _require_str(data, "principal_id"), + "issued_at": _require_timestamp(data, "issued_at"), + "expires_at": _require_timestamp(data, "expires_at"), + "constraints": constraints, + "audit_id": _optional_str(data, "audit_id"), + "signature": _optional_str(data, "signature"), + "key_id": _optional_str(data, "key_id"), + } + + +__all__ = ["KeyRing", "sign", "parse_token_fields"] diff --git a/src/weaver_kernel/cli/_doctor.py b/src/weaver_kernel/cli/_doctor.py index 2d02999..3e94417 100644 --- a/src/weaver_kernel/cli/_doctor.py +++ b/src/weaver_kernel/cli/_doctor.py @@ -19,10 +19,11 @@ import sys from dataclasses import dataclass +from .._hmac_provider import HMACTokenProvider from .._secrets import SECRET_ENV_VAR from ..models import ActionTrace from ..stores.audit_chain import build_record, verify_chain -from ..tokens import CapabilityToken, HMACTokenProvider +from ..tokens import CapabilityToken OK = "ok" WARN = "warn" diff --git a/src/weaver_kernel/coding_agent.py b/src/weaver_kernel/coding_agent.py index b9e424d..984126b 100644 --- a/src/weaver_kernel/coding_agent.py +++ b/src/weaver_kernel/coding_agent.py @@ -11,6 +11,7 @@ from dataclasses import dataclass from typing import Any, NoReturn +from .default_policy_rule_types import MIN_JUSTIFICATION from .enums import SafetyClass from .errors import DriverError, PolicyDenied from .models import ( @@ -21,7 +22,6 @@ PolicyTraceStep, Principal, ) -from .policy import _MIN_JUSTIFICATION from .policy_matching import scope_globs_match from .policy_reasons import AllowReason, DenialReason @@ -186,10 +186,10 @@ def _require_justification(cls, capability: Capability, justification: str) -> N if capability.safety_class not in (SafetyClass.WRITE, SafetyClass.DESTRUCTIVE): return stripped_len = len(justification.strip()) - if stripped_len < _MIN_JUSTIFICATION: + if stripped_len < MIN_JUSTIFICATION: cls._deny( f"{capability.safety_class.value.upper()} capabilities require a justification " - f"of at least {_MIN_JUSTIFICATION} characters after trimming whitespace.", + f"of at least {MIN_JUSTIFICATION} characters after trimming whitespace.", reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), ) diff --git a/src/weaver_kernel/coding_agent_demo.py b/src/weaver_kernel/coding_agent_demo.py index 4741776..b85c80b 100644 --- a/src/weaver_kernel/coding_agent_demo.py +++ b/src/weaver_kernel/coding_agent_demo.py @@ -13,6 +13,7 @@ import asyncio +from ._hmac_provider import HMACTokenProvider from .coding_agent import CodingAgentPolicyEngine, enforce_coding_agent_constraints from .drivers.base import ExecutionContext from .drivers.memory import InMemoryDriver @@ -31,7 +32,6 @@ from .policy_reasons import DenialReason from .registry import CapabilityRegistry from .router import StaticRouter -from .tokens import HMACTokenProvider _CAPABILITIES: tuple[tuple[str, SafetyClass, SensitivityTag], ...] = ( ("repo.read.files", SafetyClass.READ, SensitivityTag.NONE), diff --git a/src/weaver_kernel/default_policy_access_rules.py b/src/weaver_kernel/default_policy_access_rules.py new file mode 100644 index 0000000..511968e --- /dev/null +++ b/src/weaver_kernel/default_policy_access_rules.py @@ -0,0 +1,216 @@ +"""Access, sensitivity, and memory checks for the default policy chain.""" + +from __future__ import annotations + +from .default_policy_rule_types import MIN_JUSTIFICATION, RuleContext, RuleFailure +from .enums import SafetyClass, SensitivityTag +from .models import FailedCondition, PolicyTraceStep +from .policy_reasons import DenialReason + + +def _justification_failure(ctx: RuleContext, label: str) -> RuleFailure | None: + stripped_len = len(ctx.justification.strip()) + if stripped_len >= MIN_JUSTIFICATION: + return None + detail = ( + f"{label} capabilities require a justification of at least " + f"{MIN_JUSTIFICATION} characters. Got {len(ctx.justification)} characters " + f"({stripped_len} after trimming whitespace)." + ) + return RuleFailure( + detail=detail, + condition=FailedCondition( + condition="min_justification", + required=MIN_JUSTIFICATION, + actual=stripped_len, + suggestion=( + f"Provide justification with at least {MIN_JUSTIFICATION} " + f"characters (currently {stripped_len})" + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ) + + +def check_safety_class(ctx: RuleContext) -> list[RuleFailure]: + """Apply WRITE/DESTRUCTIVE role and justification requirements.""" + failures: list[RuleFailure] = [] + roles = set(ctx.principal.roles) + pid = ctx.principal.principal_id + + if ctx.capability.safety_class == SafetyClass.WRITE: + if not (roles & {"writer", "admin"}): + failures.append( + RuleFailure( + detail=( + "WRITE capabilities require the 'writer' or 'admin' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["writer", "admin"], + actual=sorted(roles), + suggestion=f"Add 'writer' or 'admin' role to principal '{pid}'", + reason_code=str(DenialReason.MISSING_ROLE), + ), + reason_code=str(DenialReason.MISSING_ROLE), + ) + ) + failure = _justification_failure(ctx, "WRITE") + if failure is not None: + failures.append(failure) + + elif ctx.capability.safety_class == SafetyClass.DESTRUCTIVE: + if "admin" not in roles: + failures.append( + RuleFailure( + detail=( + "DESTRUCTIVE capabilities require the 'admin' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["admin"], + actual=sorted(roles), + suggestion=f"Add 'admin' role to principal '{pid}'", + reason_code=str(DenialReason.MISSING_ROLE), + ), + reason_code=str(DenialReason.MISSING_ROLE), + ) + ) + failure = _justification_failure(ctx, "DESTRUCTIVE") + if failure is not None: + failures.append(failure) + + return failures + + +def check_tenant_sensitivity(ctx: RuleContext) -> list[RuleFailure]: + """Apply PII/PCI tenant requirements and allowed-field narrowing.""" + if ctx.capability.sensitivity not in (SensitivityTag.PII, SensitivityTag.PCI): + return [] + pid = ctx.principal.principal_id + if "tenant" not in ctx.principal.attributes: + return [ + RuleFailure( + detail=( + f"Capability '{ctx.capability.capability_id}' has " + f"{ctx.capability.sensitivity.value} sensitivity and requires " + "the principal to have a 'tenant' attribute." + ), + condition=FailedCondition( + condition="tenant_attribute", + required="present", + actual="absent", + suggestion=f"Add 'tenant' attribute to principal '{pid}'", + reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), + ), + reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), + ) + ] + + roles = set(ctx.principal.roles) + if ctx.capability.allowed_fields and "pii_reader" not in roles: + ctx.constraints["allowed_fields"] = ctx.capability.allowed_fields + ctx.trace_steps.append( + PolicyTraceStep( + name="sensitivity:allowed_fields", + outcome="constraint_applied", + detail=f"applied allowed_fields={ctx.capability.allowed_fields}", + ) + ) + return [] + + +def check_secrets(ctx: RuleContext) -> list[RuleFailure]: + """Apply SECRETS role and justification requirements.""" + if ctx.capability.sensitivity != SensitivityTag.SECRETS: + return [] + failures: list[RuleFailure] = [] + roles = set(ctx.principal.roles) + pid = ctx.principal.principal_id + if not (roles & {"admin", "secrets_reader"}): + failures.append( + RuleFailure( + detail=( + "SECRETS capabilities require the 'admin' or 'secrets_reader' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["admin", "secrets_reader"], + actual=sorted(roles), + suggestion=f"Add 'admin' or 'secrets_reader' role to principal '{pid}'", + reason_code=str(DenialReason.MISSING_ROLE), + ), + reason_code=str(DenialReason.MISSING_ROLE), + ) + ) + failure = _justification_failure(ctx, "SECRETS") + if failure is not None: + failures.append(failure) + return failures + + +def check_memory(ctx: RuleContext) -> list[RuleFailure]: + """Apply MEMORY write and sensitive-read role requirements.""" + if ctx.capability.sensitivity != SensitivityTag.MEMORY: + return [] + roles = set(ctx.principal.roles) + pid = ctx.principal.principal_id + memory_scope = str(ctx.request.scope.get("memory_scope", "")) if ctx.request.scope else "" + is_write = ctx.capability.safety_class in (SafetyClass.WRITE, SafetyClass.DESTRUCTIVE) + + if is_write and not (roles & {"memory_writer", "admin"}): + return [ + RuleFailure( + detail=( + "MEMORY write capabilities require the 'memory_writer' or 'admin' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["memory_writer", "admin"], + actual=sorted(roles), + suggestion=f"Add 'memory_writer' or 'admin' role to principal '{pid}'", + reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), + ), + reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), + ) + ] + + if ( + not is_write + and memory_scope == "sensitive" + and not (roles & {"memory_reader_sensitive", "admin"}) + ): + return [ + RuleFailure( + detail=( + "MEMORY read with scope='sensitive' requires the " + f"'memory_reader_sensitive' or 'admin' role. Principal '{pid}' " + f"has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["memory_reader_sensitive", "admin"], + actual=sorted(roles), + suggestion=( + f"Add 'memory_reader_sensitive' or 'admin' role to principal '{pid}' " + "(or narrow the request scope away from 'sensitive')" + ), + reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), + ), + reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), + ) + ] + return [] + + +__all__ = [ + "check_memory", + "check_safety_class", + "check_secrets", + "check_tenant_sensitivity", +] diff --git a/src/weaver_kernel/default_policy_limit_rules.py b/src/weaver_kernel/default_policy_limit_rules.py new file mode 100644 index 0000000..f41ce2c --- /dev/null +++ b/src/weaver_kernel/default_policy_limit_rules.py @@ -0,0 +1,98 @@ +"""Constraint and rate-limit checks for the default policy chain.""" + +from __future__ import annotations + +from .default_policy_rule_types import ( + MAX_ROWS_SERVICE, + MAX_ROWS_USER, + RuleContext, + RuleFailure, +) +from .models import FailedCondition, PolicyTraceStep +from .policy_reasons import DenialReason +from .rate_limit import SERVICE_RATE_MULTIPLIER + + +def apply_row_cap(ctx: RuleContext) -> list[RuleFailure]: + """Validate/cap ``max_rows`` and record the applied constraint.""" + roles = set(ctx.principal.roles) + max_rows = MAX_ROWS_SERVICE if "service" in roles else MAX_ROWS_USER + if "max_rows" in ctx.constraints: + try: + requested = int(ctx.constraints["max_rows"]) + except (TypeError, ValueError) as exc: + return [ + RuleFailure( + detail=( + f"Invalid 'max_rows' constraint: {ctx.constraints['max_rows']!r} " + "is not a valid integer." + ), + condition=FailedCondition( + condition="max_rows", + required="integer", + actual=ctx.constraints["max_rows"], + suggestion="Provide 'max_rows' as a valid integer", + reason_code=str(DenialReason.INVALID_CONSTRAINT), + ), + reason_code=str(DenialReason.INVALID_CONSTRAINT), + cause=exc, + ) + ] + ctx.constraints["max_rows"] = min(max(requested, 0), max_rows) + else: + ctx.constraints["max_rows"] = max_rows + + ctx.trace_steps.append( + PolicyTraceStep( + name="row_cap", + outcome="constraint_applied", + detail="max_rows capped", + ) + ) + return [] + + +def check_rate_limit(ctx: RuleContext) -> list[RuleFailure]: + """Check the current sliding window and record usage only for decisions.""" + safety_class = ctx.capability.safety_class + if safety_class not in ctx.rate_limits: + return [] + + roles = set(ctx.principal.roles) + limit, window = ctx.rate_limits[safety_class] + if "service" in roles: + limit *= SERVICE_RATE_MULTIPLIER + pid = ctx.principal.principal_id + cid = ctx.capability.capability_id + rate_key = f"{pid}:{cid}" + allowed = ( + ctx.limiter.peek(rate_key, limit, window) + if ctx.read_only + else ctx.limiter.check(rate_key, limit, window) + ) + if not allowed: + return [ + RuleFailure( + detail=( + f"Rate limit exceeded: {limit} {safety_class.value} " + f"invocations per {window}s for principal '{pid}'" + ), + condition=FailedCondition( + condition="rate_limit", + required=f"fewer than {limit} invocations per {window}s", + actual="limit exceeded", + suggestion=( + f"Wait for the {window}s rate-limit window before retrying " + f"capability '{cid}'" + ), + reason_code=str(DenialReason.RATE_LIMITED), + ), + reason_code=str(DenialReason.RATE_LIMITED), + ) + ] + if not ctx.read_only: + ctx.limiter.record(rate_key) + return [] + + +__all__ = ["apply_row_cap", "check_rate_limit"] diff --git a/src/weaver_kernel/default_policy_rule_types.py b/src/weaver_kernel/default_policy_rule_types.py new file mode 100644 index 0000000..89aa4de --- /dev/null +++ b/src/weaver_kernel/default_policy_rule_types.py @@ -0,0 +1,64 @@ +"""Internal data structures shared by the default-policy rule modules.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .enums import SafetyClass +from .models import ( + Capability, + CapabilityRequest, + FailedCondition, + PolicyTraceStep, + Principal, +) +from .rate_limit import RateLimiter + +MIN_JUSTIFICATION = 15 +MAX_ROWS_USER = 50 +MAX_ROWS_SERVICE = 500 + + +@dataclass(slots=True) +class RuleFailure: + """One failed default-policy condition and its decision/explanation views.""" + + detail: str + condition: FailedCondition + reason_code: str + cause: Exception | None = None + + +@dataclass(slots=True) +class RuleContext: + """Mutable traversal context shared by the ordered rule checks.""" + + request: CapabilityRequest + capability: Capability + principal: Principal + justification: str + constraints: dict[str, Any] + rate_limits: dict[SafetyClass, tuple[int, float]] + limiter: RateLimiter + read_only: bool + trace_steps: list[PolicyTraceStep] = field(default_factory=list) + + +@dataclass(slots=True) +class RuleChainResult: + """Result of traversing the ordered default-policy rule chain.""" + + constraints: dict[str, Any] + failures: list[RuleFailure] = field(default_factory=list) + trace_steps: list[PolicyTraceStep] = field(default_factory=list) + + +__all__ = [ + "MAX_ROWS_SERVICE", + "MAX_ROWS_USER", + "MIN_JUSTIFICATION", + "RuleChainResult", + "RuleContext", + "RuleFailure", +] diff --git a/src/weaver_kernel/default_policy_rules.py b/src/weaver_kernel/default_policy_rules.py new file mode 100644 index 0000000..afbbf6f --- /dev/null +++ b/src/weaver_kernel/default_policy_rules.py @@ -0,0 +1,112 @@ +"""Ordered rule chain shared by default policy decisions and explanations.""" + +from __future__ import annotations + +from collections.abc import Callable + +from .default_policy_access_rules import ( + check_memory, + check_safety_class, + check_secrets, + check_tenant_sensitivity, +) +from .default_policy_limit_rules import apply_row_cap, check_rate_limit +from .default_policy_rule_types import ( + MAX_ROWS_SERVICE, + MAX_ROWS_USER, + MIN_JUSTIFICATION, + RuleChainResult, + RuleContext, + RuleFailure, +) +from .enums import SafetyClass +from .models import Capability, CapabilityRequest, Principal +from .rate_limit import RateLimiter + +RuleCheck = Callable[[RuleContext], list[RuleFailure]] + +# Canonical order is defined once here. Both evaluate() and explain() traverse +# exactly this sequence; their only differences are short-circuit/collect-all +# and stateful/read-only rate-limit modes. +_DEFAULT_RULES: tuple[RuleCheck, ...] = ( + check_safety_class, + check_tenant_sensitivity, + check_secrets, + check_memory, + apply_row_cap, + check_rate_limit, +) + + +class DefaultPolicyRuleChain: + """Single ordered definition of the built-in default policy rules.""" + + def __init__( + self, + *, + rate_limits: dict[SafetyClass, tuple[int, float]], + limiter: RateLimiter, + ) -> None: + self._rate_limits = rate_limits + self._limiter = limiter + + def run( + self, + request: CapabilityRequest, + capability: Capability, + principal: Principal, + *, + justification: str, + collect_all: bool, + read_only: bool, + ) -> RuleChainResult: + """Traverse the canonical rules in decision or explanation mode. + + Args: + request: Capability request being checked. + capability: Target capability. + principal: Requesting principal. + justification: Caller-supplied justification. + collect_all: Collect every failed condition instead of stopping at + the first one. + read_only: Avoid policy-state mutation, including rate-window + creation, pruning, and usage recording. + + Returns: + Constraints, failures, and non-terminal trace steps produced by the + common rule traversal. + """ + ctx = RuleContext( + request=request, + capability=capability, + principal=principal, + justification=justification, + constraints=dict(request.constraints), + rate_limits=self._rate_limits, + limiter=self._limiter, + read_only=read_only, + ) + failures: list[RuleFailure] = [] + for rule in _DEFAULT_RULES: + rule_failures = rule(ctx) + if rule_failures: + failures.extend(rule_failures) + if not collect_all: + failures = failures[:1] + break + + return RuleChainResult( + constraints=ctx.constraints, + failures=failures, + trace_steps=ctx.trace_steps, + ) + + +__all__ = [ + "DefaultPolicyRuleChain", + "MAX_ROWS_SERVICE", + "MAX_ROWS_USER", + "MIN_JUSTIFICATION", + "RuleChainResult", + "RuleFailure", +] diff --git a/src/weaver_kernel/drivers/mcp.py b/src/weaver_kernel/drivers/mcp.py index 5594c0e..c692493 100644 --- a/src/weaver_kernel/drivers/mcp.py +++ b/src/weaver_kernel/drivers/mcp.py @@ -10,9 +10,9 @@ from ..errors import DriverError from ..models import Capability, ImplementationRef, RawResult from .base import ExecutionContext +from .mcp_classification import UnannotatedSafety, classify_tool_specs from .mcp_support import ( SessionFactory, - ToolSpec, build_http_session_factory, build_stdio_session_factory, call_tool, @@ -51,19 +51,6 @@ def _load_mcp_error() -> type[BaseException] | None: _McpError: type[BaseException] | None = _load_mcp_error() -def _infer_safety_class(spec: ToolSpec) -> SafetyClass: - """Infer a SafetyClass from MCP ToolAnnotations hints. - - Uses a conservative default of READ when annotations are absent. - The caller's safety_class_map takes precedence over the inferred value. - """ - if spec.destructive_hint: - return SafetyClass.DESTRUCTIVE - if spec.read_only_hint: - return SafetyClass.READ - return SafetyClass.READ - - class MCPDriver: """A driver that invokes capabilities via MCP tools/call.""" @@ -136,22 +123,29 @@ async def discover( *, namespace: str | None = None, safety_class_map: dict[str, SafetyClass] | None = None, + unannotated_safety: UnannotatedSafety = "reject", ) -> list[Capability]: - """Discover MCP tools across all pages and convert them to capabilities.""" + """Discover MCP tools and convert them to deliberately classified capabilities. + + Explicit ``safety_class_map`` entries take precedence over advisory MCP + annotations. Tools with neither an override nor a useful annotation are + rejected by default. Pass a ``SafetyClass`` via ``unannotated_safety`` + only when an operator deliberately accepts one fallback classification. + """ tools = await self._run_with_retry( operation_name="tools/list", action=self._fetch_all_tools, ) + classified = classify_tool_specs( + extract_tool_specs(tools), + driver_id=self._driver_id, + safety_class_map=safety_class_map, + unannotated_safety=unannotated_safety, + ) capabilities: list[Capability] = [] - for spec in extract_tool_specs(tools): + for spec, safety_class in classified: capability_id = f"{namespace}.{spec.name}" if namespace else spec.name - inferred = _infer_safety_class(spec) - safety_class = ( - safety_class_map.get(spec.name, inferred) - if safety_class_map is not None - else inferred - ) capabilities.append( Capability( capability_id=capability_id, diff --git a/src/weaver_kernel/drivers/mcp_classification.py b/src/weaver_kernel/drivers/mcp_classification.py new file mode 100644 index 0000000..e6041a6 --- /dev/null +++ b/src/weaver_kernel/drivers/mcp_classification.py @@ -0,0 +1,95 @@ +"""Fail-closed safety classification for MCP tool discovery.""" + +from __future__ import annotations + +import logging +from typing import Literal, cast + +from ..enums import SafetyClass +from ..errors import DriverError +from .mcp_support import ToolSpec + +logger = logging.getLogger("weaver_kernel.drivers.mcp") + +UnannotatedSafety = SafetyClass | Literal["reject"] +"""How discovery handles a tool with no usable MCP safety annotation.""" + + +def infer_safety_class(spec: ToolSpec) -> SafetyClass | None: + """Infer safety only from explicit MCP annotation hints. + + MCP annotations are advisory metadata, not authorization statements. + Destructive wins if a server supplies conflicting read/destructive hints. + Missing/ambiguous authority is represented as ``None`` rather than READ. + """ + if spec.destructive_hint: + return SafetyClass.DESTRUCTIVE + if spec.read_only_hint: + return SafetyClass.READ + return None + + +def classify_tool_specs( + specs: list[ToolSpec], + *, + driver_id: str, + safety_class_map: dict[str, SafetyClass] | None, + unannotated_safety: UnannotatedSafety, +) -> list[tuple[ToolSpec, SafetyClass]]: + """Classify discovered MCP tools without silently granting unknown authority. + + Explicit operator mappings take precedence over advisory MCP annotations. + Otherwise-unclassified tools are rejected by default; an explicit fallback + is allowed but logged with every affected tool name. + """ + if unannotated_safety != "reject" and not isinstance(unannotated_safety, SafetyClass): + raise DriverError( + f"unannotated_safety must be 'reject' or a SafetyClass, got {unannotated_safety!r}." + ) + + classified: list[tuple[ToolSpec, SafetyClass]] = [] + rejected: list[str] = [] + fallback: list[str] = [] + + for spec in specs: + if safety_class_map is not None and spec.name in safety_class_map: + configured = safety_class_map[spec.name] + if not isinstance(configured, SafetyClass): + raise DriverError( + f"safety_class_map[{spec.name!r}] must be a SafetyClass, got {configured!r}." + ) + safety_class = configured + else: + inferred = infer_safety_class(spec) + if inferred is not None: + safety_class = inferred + elif unannotated_safety == "reject": + rejected.append(spec.name) + continue + else: + safety_class = unannotated_safety + fallback.append(spec.name) + classified.append((spec, safety_class)) + + if rejected: + names = ", ".join(sorted(rejected)) + raise DriverError( + f"MCPDriver '{driver_id}' refused to discover/classify unclassified tools: {names}. " + "Classify them with safety_class_map or explicitly set unannotated_safety to a " + "SafetyClass. MCP annotations are advisory and missing metadata is not treated " + "as READ authority." + ) + + if fallback: + safe_fallback_class = cast(SafetyClass, unannotated_safety) # narrowed above + logger.warning( + "mcp_unannotated_safety_fallback driver_id=%s safety_class=%s tools=%r", + driver_id, + safe_fallback_class.value, + sorted(fallback), + ) + + return classified + + +__all__ = ["UnannotatedSafety", "classify_tool_specs", "infer_safety_class"] diff --git a/src/weaver_kernel/errors.py b/src/weaver_kernel/errors.py index 245299e..fa0e1b1 100644 --- a/src/weaver_kernel/errors.py +++ b/src/weaver_kernel/errors.py @@ -17,7 +17,17 @@ 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 its bound scope. + + Covers a token presented by the wrong principal or for the wrong capability, + and invocation arguments that violate a signed ``constraints["args"]`` rule + (#183). Carries an optional stable ``reason_code`` (the same vocabulary as + :class:`PolicyDenied`) so metrics and UI mapping use one denial taxonomy. + """ + + def __init__(self, message: str, *, reason_code: str | None = None) -> None: + super().__init__(message) + self.reason_code: str | None = reason_code class TokenRevoked(AgentKernelError): diff --git a/src/weaver_kernel/kernel/__init__.py b/src/weaver_kernel/kernel/__init__.py index 2a20899..6d045c4 100644 --- a/src/weaver_kernel/kernel/__init__.py +++ b/src/weaver_kernel/kernel/__init__.py @@ -11,11 +11,13 @@ import logging import uuid -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from typing import Any, Literal, overload +from .._hmac_provider import HMACTokenProvider 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,20 +37,23 @@ 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 from ..stores import TraceStoreProtocol -from ..tokens import CapabilityToken, HMACTokenProvider, TokenProvider +from ..tokens import CapabilityToken, 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 ._constraints import run_pre_invoke_checks, validate_invoke_rate_limits 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 +91,11 @@ 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_clock: Callable[[], float] | None = None, ) -> None: + # invoke_rate_limits (#170): optional invoke-time limits, default off. + validate_invoke_rate_limits(invoke_rate_limits) self._registry = registry self._policy: PolicyEngine = policy or DefaultPolicyEngine() self._token_provider: TokenProvider = token_provider or HMACTokenProvider() @@ -98,6 +107,8 @@ def __init__( self._drivers: dict[str, Driver] = {} self._kernel_id = kernel_id self._stats = KernelStats() + self._invoke_rate_limits: dict[SafetyClass, tuple[int, float]] = invoke_rate_limits or {} + self._invoke_limiter = RateLimiter(clock=invoke_rate_clock) @property def kernel_id(self) -> str: @@ -137,6 +148,7 @@ def grant_capability( principal: Principal, *, justification: str, + ttl_s: int | None = None, ) -> CapabilityGrant: """Evaluate the policy and, if approved, issue a signed token. @@ -146,61 +158,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 being granted. + principal: The principal the grant is issued to. + justification: Free-text justification forwarded to the policy engine. + ttl_s: Optional per-grant token time-to-live in seconds (#203). + ``None`` uses the token provider's default. A non-positive value, + or one exceeding the policy's ``max_ttl_s``, is denied (never + silently clamped). """ - 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, @@ -208,9 +176,19 @@ def get_token( principal: Principal, *, justification: str, + ttl_s: int | None = None, ) -> CapabilityToken: - """Like :meth:`grant_capability` but returns the token directly.""" - return self.grant_capability(request, principal, justification=justification).token + """Like :meth:`grant_capability` but returns the token directly. + + Args: + request: The capability request being granted. + principal: The principal the grant is issued to. + justification: Free-text justification forwarded to the policy engine. + ttl_s: Optional per-grant token time-to-live in seconds (#203). + """ + return self.grant_capability( + request, principal, justification=justification, ttl_s=ttl_s + ).token @overload async def invoke( @@ -256,6 +234,16 @@ async def invoke( ) capability = self._registry.get(token.capability_id) plan: RoutePlan = self._router.route(token.capability_id) + # Invoke-time enforcement (#183 arg constraints, #170 rate limits). + run_pre_invoke_checks( + self, + token=token, + capability=capability, + principal=principal, + args=args, + response_mode=response_mode, + dry_run=dry_run, + ) if dry_run: return build_dry_run_result( token=token, @@ -314,6 +302,16 @@ async def invoke_stream( ) capability = self._registry.get(token.capability_id) plan: RoutePlan = self._router.route(token.capability_id) + # Same invoke-time enforcement as the single-shot path (#183, #170). + run_pre_invoke_checks( + self, + token=token, + capability=capability, + principal=principal, + args=args, + response_mode=response_mode, + dry_run=False, + ) async for frame in invoke_stream_impl( kernel=self, token=token, diff --git a/src/weaver_kernel/kernel/_constraints.py b/src/weaver_kernel/kernel/_constraints.py new file mode 100644 index 0000000..18525ec --- /dev/null +++ b/src/weaver_kernel/kernel/_constraints.py @@ -0,0 +1,223 @@ +"""Invoke-time enforcement: signed argument constraints (#183) and per-invocation +rate limiting (#170). + +Both checks run on the execution path — *after* token verification, *before* the +driver runs and *before* budget is reserved — so a violation costs nothing +downstream. They are invoked from :meth:`Kernel.invoke` and +:meth:`Kernel.invoke_stream` alike (via :func:`run_pre_invoke_checks`), so the +streaming path is covered too. Dry-run evaluates the identical checks for parity +but never records rate-limit usage. On a real (non-dry-run) violation a failure +:class:`~weaver_kernel.models.ActionTrace` is recorded before the error +propagates, so I-02 (auditability) holds. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from ..enums import SafetyClass +from ..errors import AgentKernelError, PolicyDenied, TokenScopeError +from ..models import Capability, Principal, ResponseMode +from ..policy_reasons import DenialReason +from ..tokens import CapabilityToken +from ._invoke import record_failure_trace + +if TYPE_CHECKING: # pragma: no cover + from . import Kernel + +ARG_CONSTRAINTS_KEY = "args" +"""Token-constraint key carrying the argument-level rules enforced at invoke (#183).""" + + +def validate_invoke_rate_limits( + limits: dict[SafetyClass, tuple[int, float]] | None, +) -> None: + """Reject a malformed ``invoke_rate_limits`` configuration at construction (#170). + + Args: + limits: The per-safety-class ``(max_invocations, window_seconds)`` map, or + ``None``. + + Raises: + AgentKernelError: If any limit is < 1 or any window is <= 0. + """ + if not limits: + return + for safety_class, (limit, window) in limits.items(): + if limit < 1 or window <= 0: + raise AgentKernelError( + f"Invalid invoke_rate_limits for {safety_class.value}: limit must be " + f">= 1 and window must be > 0, got limit={limit}, window={window}." + ) + + +def enforce_arg_constraints(args: dict[str, Any], constraints: dict[str, Any]) -> None: + """Enforce a token's signed ``constraints["args"]`` against invocation *args* (#183). + + The v1 vocabulary is deliberately tiny and deterministic — no expression + language (see ``docs/agent-context/invariants.md`` on determinism): + + * ``allowed_keys``: every argument key must be in this list. + * ``pinned``: each named key must be present and exactly equal to the value. + * ``prefix``: each named key must be a string starting with the given prefix. + + Only top-level argument keys are inspected in v1. A ``pinned``/``prefix`` rule + on an omitted argument fails closed. + + Args: + args: The invocation arguments. + constraints: The verified token's ``constraints`` mapping. + + Raises: + TokenScopeError: If any argument violates the spec, carrying + :attr:`~weaver_kernel.policy_reasons.DenialReason.ARG_CONSTRAINT_VIOLATION`. + """ + spec = constraints.get(ARG_CONSTRAINTS_KEY) + if not spec: + return + if not isinstance(spec, dict): + raise _violation(f"malformed 'args' constraint: expected an object, got {spec!r}.") + + # A malformed rule value is a security-scoping misconfiguration — fail closed + # (deny) rather than silently ignoring it (fail open) or crashing with an + # untyped TypeError that would escape the audited denial path. + allowed = spec.get("allowed_keys") + if allowed is not None: + if not isinstance(allowed, (list, tuple, set)): + raise _violation("malformed 'args.allowed_keys' constraint: expected a list.") + # Every element must be a (hashable) string; a non-string — including an + # unhashable list/dict — would otherwise blow up `set(allowed)` with a + # TypeError that escapes the audited denial path. + if not all(isinstance(k, str) for k in allowed): + raise _violation( + "malformed 'args.allowed_keys' constraint: expected a list of strings." + ) + permitted = set(allowed) + extra = sorted(k for k in args if k not in permitted) + if extra: + raise _violation(f"arguments {extra} are not permitted by the token's allowed_keys.") + + pinned = spec.get("pinned") + if pinned is not None: + if not isinstance(pinned, dict): + raise _violation("malformed 'args.pinned' constraint: expected an object.") + for key, expected in pinned.items(): + if key not in args or args[key] != expected: + raise _violation(f"argument '{key}' must equal the token's pinned value.") + + prefix = spec.get("prefix") + if prefix is not None: + if not isinstance(prefix, dict): + raise _violation("malformed 'args.prefix' constraint: expected an object.") + for key, required_prefix in prefix.items(): + # The pinned prefix itself must be a string; otherwise `startswith` + # raises TypeError on an otherwise-valid string argument. + if not isinstance(required_prefix, str): + raise _violation( + f"malformed 'args.prefix' constraint for '{key}': expected a string prefix." + ) + value = args.get(key) + if not isinstance(value, str) or not value.startswith(required_prefix): + raise _violation( + f"argument '{key}' must be a string starting with '{required_prefix}'." + ) + + +def _violation(message: str) -> TokenScopeError: + """Build a :class:`TokenScopeError` for an argument-constraint violation.""" + return TokenScopeError(message, reason_code=DenialReason.ARG_CONSTRAINT_VIOLATION) + + +def _check_invoke_rate( + kernel: Kernel, + token: CapabilityToken, + capability: Capability, + principal: Principal, + *, + dry_run: bool, +) -> None: + """Apply the optional invoke-time sliding-window rate limit (#170). + + Independent of and additional to the grant-time limit. The check-then-record + pair runs with no ``await`` between them, so concurrent invokes cannot + over-admit. Dry-run checks but never records. + """ + limits = kernel._invoke_rate_limits + limit_window = limits.get(capability.safety_class) + if limit_window is None: + return + limit, window = limit_window + key = f"{principal.principal_id}:{token.capability_id}" + if not kernel._invoke_limiter.check(key, limit, window): + raise PolicyDenied( + f"Invoke-time rate limit exceeded: {limit} {capability.safety_class.value} " + f"invocations per {window}s for principal '{principal.principal_id}'.", + reason_code=DenialReason.RATE_LIMITED, + ) + if not dry_run: + kernel._invoke_limiter.record(key) + + +def run_pre_invoke_checks( + kernel: Kernel, + *, + token: CapabilityToken, + capability: Capability, + principal: Principal, + args: dict[str, Any], + response_mode: ResponseMode, + dry_run: bool, +) -> None: + """Run invoke-time argument-constraint and rate-limit checks (#183, #170). + + Args: + kernel: The orchestrating :class:`Kernel`. + token: The already-verified capability token. + capability: The resolved capability (its sensitivity tags the audit trace). + principal: The invoking principal. + args: The invocation arguments. + response_mode: The caller-requested response mode (recorded on a denial trace). + dry_run: When ``True``, evaluate the checks for parity but record no + rate-limit usage and write no audit trace. + + Raises: + TokenScopeError: If *args* violate a signed argument constraint (#183). + PolicyDenied: If the invoke-time rate limit is exceeded (#170). + """ + try: + enforce_arg_constraints(args, token.constraints) + _check_invoke_rate(kernel, token, capability, principal, dry_run=dry_run) + except (TokenScopeError, PolicyDenied) as exc: + if not dry_run: + # I-02: a denied execution attempt is still auditable. No driver ran + # and no budget was reserved, so record a failure trace directly. + record_failure_trace( + action_id=str(uuid.uuid4()), + capability_id=token.capability_id, + principal_id=principal.principal_id, + token_id=token.token_id, + args=args, + response_mode=response_mode, + error_message=str(exc), + trace_store=kernel._traces, + sensitivity=capability.sensitivity, + driver_id="", + ) + kernel._stats.on_invocation( + failed=True, fallback=False, redacted=False, downgraded=False + ) + raise + + +# Re-exported for the kernel constructor's clock injection typing. +InvokeRateClock = Callable[[], float] + +__all__ = [ + "ARG_CONSTRAINTS_KEY", + "enforce_arg_constraints", + "run_pre_invoke_checks", + "validate_invoke_rate_limits", + "InvokeRateClock", +] diff --git a/src/weaver_kernel/kernel/_grant.py b/src/weaver_kernel/kernel/_grant.py new file mode 100644 index 0000000..7e60ffe --- /dev/null +++ b/src/weaver_kernel/kernel/_grant.py @@ -0,0 +1,144 @@ +"""Grant-capability orchestration and per-grant TTL enforcement (#203). + +Extracted from :mod:`weaver_kernel.kernel` to keep the public API module within +the AGENTS.md 300-line budget (mirrors the ``_invoke``/``_dry_run`` split). The +per-grant TTL is validated *before* policy evaluation so a doomed grant never +consumes rate-limit quota, then threaded into token issuance. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import TYPE_CHECKING + +from ..errors import PolicyDenied +from ..models import Capability, CapabilityGrant, CapabilityRequest, Principal +from ..policy import PolicyEngine +from ..policy_reasons import DenialReason +from ..policy_ttl import resolve_max_ttl_s +from ._audit import record_denial_trace + +if TYPE_CHECKING: # pragma: no cover + from . import Kernel + +logger = logging.getLogger("weaver_kernel.kernel") + + +def _validate_ttl(policy: PolicyEngine, capability: Capability, ttl_s: int | None) -> None: + """Deny a per-grant TTL that is non-positive or over the policy maximum (#203). + + Args: + policy: The active policy engine; consulted (duck-typed) for + ``max_ttl_s`` so third-party engines without it impose no maximum. + capability: The capability being granted. + ttl_s: The requested TTL in seconds, or ``None`` for the provider default. + + Raises: + PolicyDenied: If *ttl_s* is non-positive (``INVALID_CONSTRAINT``) or + exceeds the policy maximum (``TTL_EXCEEDED``). Never clamps silently. + """ + if ttl_s is None: + return + if ttl_s <= 0: + raise PolicyDenied( + f"Requested ttl_s must be a positive number of seconds, got {ttl_s}.", + reason_code=DenialReason.INVALID_CONSTRAINT, + ) + max_ttl = resolve_max_ttl_s(getattr(policy, "max_ttl_s", None), capability) + if max_ttl is not None and ttl_s > max_ttl: + raise PolicyDenied( + f"Requested ttl_s={ttl_s} exceeds the maximum of {max_ttl}s for " + f"{capability.safety_class.value} capabilities.", + reason_code=DenialReason.TTL_EXCEEDED, + ) + + +def perform_grant( + kernel: Kernel, + request: CapabilityRequest, + principal: Principal, + *, + justification: str, + ttl_s: int | None, +) -> CapabilityGrant: + """Evaluate the policy and, if approved, issue a signed token. + + On a :class:`~weaver_kernel.PolicyDenied` rejection — including a TTL denial + raised before evaluation — a ``"deny"`` audit record (carrying the stable + reason code) is written to the trace store (best-effort) before the exception + propagates. A trace-store write failure is logged but never masks the denial. + + Args: + kernel: The orchestrating :class:`Kernel` (private accessors used for the + registry, policy, token provider, trace store, and stats). + request: The capability request being granted. + principal: The principal the grant is issued to. + justification: Free-text justification forwarded to the policy engine. + ttl_s: Optional per-grant token TTL in seconds; ``None`` uses the token + provider's default. + + Returns: + The issued :class:`~weaver_kernel.models.CapabilityGrant`. + """ + capability = kernel._registry.get(request.capability_id) + try: + _validate_ttl(kernel._policy, capability, ttl_s) + decision = kernel._policy.evaluate( + request, capability, principal, justification=justification + ) + 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._traces, + ) + 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 = {} if ttl_s is None else {"ttl_seconds": ttl_s} + token = kernel._token_provider.issue( + capability.capability_id, + principal.principal_id, + constraints=decision.constraints, + audit_id=audit_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, + "ttl_s": ttl_s, + }, + ) + 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/otel.py b/src/weaver_kernel/otel.py index e21a688..4a01947 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: int | 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..c30f1f4 100644 --- a/src/weaver_kernel/policy.py +++ b/src/weaver_kernel/policy.py @@ -4,32 +4,26 @@ import logging from collections.abc import Callable -from typing import Any, Protocol +from typing import Protocol -from .enums import SafetyClass, SensitivityTag +from .default_policy_rules import DefaultPolicyRuleChain +from .enums import SafetyClass from .errors import AgentKernelError, PolicyDenied from .models import ( Capability, CapabilityRequest, DenialExplanation, - FailedCondition, PolicyDecision, PolicyDecisionTrace, PolicyTraceStep, Principal, ) -from .policy_reasons import AllowReason, DenialReason +from .policy_reasons import AllowReason +from .policy_ttl import validate_max_ttl_s from .rate_limit import DEFAULT_RATE_LIMITS, SERVICE_RATE_MULTIPLIER, RateLimiter logger = logging.getLogger(__name__) -# Minimum justification length for WRITE operations. -_MIN_JUSTIFICATION = 15 - -# Default max_rows caps. -_MAX_ROWS_USER = 50 -_MAX_ROWS_SERVICE = 500 - # Backwards-compatible aliases — these used to be defined here. New code # should import the names without the leading underscore from ``rate_limit``. _DEFAULT_RATE_LIMITS = DEFAULT_RATE_LIMITS @@ -133,6 +127,7 @@ def __init__( *, rate_limits: dict[SafetyClass, tuple[int, float]] | None = None, clock: Callable[[], float] | None = None, + max_ttl_s: int | dict[SafetyClass, int] | None = None, ) -> None: """Initialise the policy engine. @@ -143,8 +138,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: Maximum per-grant token TTL in seconds — one cap or a per-safety-class map; ``None`` = uncapped. A longer request is denied, not clamped (#203). """ - limits = dict(_DEFAULT_RATE_LIMITS) + limits = dict(DEFAULT_RATE_LIMITS) if rate_limits is not None: limits.update(rate_limits) for sc, (count, window) in limits.items(): @@ -154,8 +150,15 @@ def __init__( f"limit must be >= 1 and window must be > 0, " f"got limit={count}, window={window}." ) + validate_max_ttl_s(max_ttl_s) self._rate_limits = limits self._limiter = RateLimiter(clock=clock) + self._rule_chain = DefaultPolicyRuleChain( + rate_limits=self._rate_limits, limiter=self._limiter + ) + # Per-grant TTL cap (#203): read by perform_grant to bound/deny requested + # token lifetimes. ``None`` = uncapped. Validated above. + self.max_ttl_s = max_ttl_s @staticmethod def _deny( @@ -185,27 +188,14 @@ def evaluate( *, justification: str, ) -> PolicyDecision: - """Evaluate the request against the default policy rules. - - Args: - request: The capability request being evaluated. - capability: The target capability. - principal: The requesting principal. - justification: Free-text justification from the caller. + """Evaluate the request against the shared default-policy rule chain. - Returns: - :class:`PolicyDecision` with ``allowed=True`` and any imposed - constraints, or raises :class:`PolicyDenied`. - - Raises: - PolicyDenied: When the request violates a policy rule. + Decision traversal short-circuits on the first denial and may update + transient policy state (currently the sliding-window rate limiter). + ``explain()`` traverses this exact same chain in read-only mode. """ - roles = set(principal.roles) - constraints: dict[str, Any] = dict(request.constraints) - pid = principal.principal_id cid = capability.capability_id - trace = PolicyDecisionTrace( engine="DefaultPolicyEngine", capability_id=cid, @@ -213,227 +203,37 @@ def evaluate( intent=request.intent, scope_keys=sorted(request.scope.keys()), ) + result = self._rule_chain.run( + request, + capability, + principal, + justification=justification, + collect_all=False, + read_only=False, + ) + trace.steps.extend(result.trace_steps) - def _record_deny(detail: str, code: str) -> None: + if result.failures: + failure = result.failures[0] trace.steps.append( PolicyTraceStep( name="deny", outcome="denied", - detail=detail, - reason_code=code, + detail=failure.detail, + reason_code=failure.reason_code, ) ) trace.final_outcome = "denied" - trace.final_reason_code = code - - # ── Safety class checks ─────────────────────────────────────────────── - - if capability.safety_class == SafetyClass.WRITE: - if not (roles & {"writer", "admin"}): - detail = ( - f"WRITE capabilities require the 'writer' or 'admin' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MISSING_ROLE) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MISSING_ROLE, - ) - stripped_len = len(justification.strip()) - if stripped_len < _MIN_JUSTIFICATION: - detail = ( - f"WRITE capabilities require a justification of at least " - f"{_MIN_JUSTIFICATION} characters. " - f"Got {len(justification)} characters " - f"({stripped_len} after trimming whitespace)." - ) - _record_deny(detail, DenialReason.INSUFFICIENT_JUSTIFICATION) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.INSUFFICIENT_JUSTIFICATION, - ) - - elif capability.safety_class == SafetyClass.DESTRUCTIVE: - if "admin" not in roles: - detail = ( - f"DESTRUCTIVE capabilities require the 'admin' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MISSING_ROLE) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MISSING_ROLE, - ) - stripped_len = len(justification.strip()) - if stripped_len < _MIN_JUSTIFICATION: - detail = ( - f"DESTRUCTIVE capabilities require a justification of at least " - f"{_MIN_JUSTIFICATION} characters. " - f"Got {len(justification)} characters " - f"({stripped_len} after trimming whitespace)." - ) - _record_deny(detail, DenialReason.INSUFFICIENT_JUSTIFICATION) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.INSUFFICIENT_JUSTIFICATION, - ) - - # ── Sensitivity checks ──────────────────────────────────────────────── - - if capability.sensitivity in (SensitivityTag.PII, SensitivityTag.PCI): - if "tenant" not in principal.attributes: - detail = ( - f"Capability '{cid}' has " - f"{capability.sensitivity.value} sensitivity and requires " - "the principal to have a 'tenant' attribute." - ) - _record_deny(detail, DenialReason.MISSING_TENANT_ATTRIBUTE) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MISSING_TENANT_ATTRIBUTE, - ) - # Enforce allowed_fields unless the principal is a pii_reader. - if capability.allowed_fields and "pii_reader" not in roles: - constraints["allowed_fields"] = capability.allowed_fields - trace.steps.append( - PolicyTraceStep( - name="sensitivity:allowed_fields", - outcome="constraint_applied", - detail=f"applied allowed_fields={capability.allowed_fields}", - ) - ) - - if capability.sensitivity == SensitivityTag.SECRETS: - if not (roles & {"admin", "secrets_reader"}): - detail = ( - f"SECRETS capabilities require the 'admin' or 'secrets_reader' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MISSING_ROLE) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MISSING_ROLE, - ) - stripped_len = len(justification.strip()) - if stripped_len < _MIN_JUSTIFICATION: - detail = ( - f"SECRETS capabilities require a justification of at least " - f"{_MIN_JUSTIFICATION} characters. " - f"Got {len(justification)} characters " - f"({stripped_len} after trimming whitespace)." - ) - _record_deny(detail, DenialReason.INSUFFICIENT_JUSTIFICATION) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.INSUFFICIENT_JUSTIFICATION, - ) - - # ── Memory action checks ───────────────────────────────────────────── - # Placed AFTER all other sensitivity checks (see invariants.md: - # "rule placement matters"). Memory reads at scope == "sensitive" - # require an explicit reader role; memory writes are treated as - # higher-risk than reads because they persist into future sessions - # and require the 'memory_writer' role (or 'admin'). - if capability.sensitivity == SensitivityTag.MEMORY: - memory_scope = str(request.scope.get("memory_scope", "")) if request.scope else "" - is_write = capability.safety_class in ( - SafetyClass.WRITE, - SafetyClass.DESTRUCTIVE, + trace.final_reason_code = failure.reason_code + denial = self._deny( + failure.detail, + principal_id=pid, + capability_id=cid, + reason_code=failure.reason_code, ) - if is_write and not (roles & {"memory_writer", "admin"}): - detail = ( - f"MEMORY write capabilities require the 'memory_writer' or " - f"'admin' role. Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MEMORY_WRITE_REQUIRES_WRITER) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MEMORY_WRITE_REQUIRES_WRITER, - ) - if ( - not is_write - and memory_scope == "sensitive" - and not (roles & {"memory_reader_sensitive", "admin"}) - ): - detail = ( - f"MEMORY read with scope='sensitive' requires the " - f"'memory_reader_sensitive' or 'admin' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MEMORY_SENSITIVE_READ_DENIED) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MEMORY_SENSITIVE_READ_DENIED, - ) - - # ── Row cap ─────────────────────────────────────────────────────────── - - max_rows = _MAX_ROWS_SERVICE if "service" in roles else _MAX_ROWS_USER - # Respect any tighter constraint from the request itself. - if "max_rows" in constraints: - try: - requested = int(constraints["max_rows"]) - except (TypeError, ValueError) as exc: - detail = ( - f"Invalid 'max_rows' constraint: {constraints['max_rows']!r} " - "is not a valid integer." - ) - _record_deny(detail, DenialReason.INVALID_CONSTRAINT) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.INVALID_CONSTRAINT, - ) from exc - constraints["max_rows"] = min(max(requested, 0), max_rows) - else: - constraints["max_rows"] = max_rows - trace.steps.append( - PolicyTraceStep( - name="row_cap", - outcome="constraint_applied", - detail="max_rows capped", - ) - ) - - # ── Rate limiting ───────────────────────────────────────────────── - - rate_key = f"{pid}:{cid}" - if capability.safety_class in self._rate_limits: - limit, window = self._rate_limits[capability.safety_class] - if "service" in roles: - limit *= _SERVICE_RATE_MULTIPLIER - if not self._limiter.check(rate_key, limit, window): - detail = ( - f"Rate limit exceeded: {limit} {capability.safety_class.value} " - f"invocations per {window}s for principal '{pid}'" - ) - _record_deny(detail, DenialReason.RATE_LIMITED) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.RATE_LIMITED, - ) - self._limiter.record(rate_key) + if failure.cause is not None: + raise denial from failure.cause + raise denial reason = "Request approved by DefaultPolicyEngine." trace.steps.append( @@ -458,7 +258,7 @@ def _record_deny(detail: str, code: str) -> None: return PolicyDecision( allowed=True, reason=reason, - constraints=constraints, + constraints=result.constraints, reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), trace=trace, ) @@ -471,160 +271,25 @@ def explain( *, justification: str, ) -> DenialExplanation: - """Explain which policy conditions would deny *principal*'s *request*. - - Traverses the same rule chain as :meth:`evaluate` but collects ALL - failing conditions instead of short-circuiting on the first failure. - Rate-limit state is excluded — it is transient and not remediable - by changing the request. - - Args: - request: The capability request to explain. - capability: The target capability. - principal: The requesting principal. - justification: Free-text justification from the caller. + """Explain all failures from the same chain used by :meth:`evaluate`. - Returns: - :class:`DenialExplanation` with ``denied=False`` if allowed. + Explanation is strictly read-only: it collects all failed conditions, + including the current rate-limit condition, without recording usage or + pruning/creating limiter windows. """ - roles = set(principal.roles) pid = principal.principal_id cid = capability.capability_id - failed: list[FailedCondition] = [] - - # ── Safety class checks ─────────────────────────────────────────────── - - if capability.safety_class == SafetyClass.WRITE: - if not (roles & {"writer", "admin"}): - failed.append( - FailedCondition( - condition="roles", - required=["writer", "admin"], - actual=sorted(roles), - suggestion=f"Add 'writer' or 'admin' role to principal '{pid}'", - reason_code=str(DenialReason.MISSING_ROLE), - ) - ) - stripped = len(justification.strip()) - if stripped < _MIN_JUSTIFICATION: - failed.append( - FailedCondition( - condition="min_justification", - required=_MIN_JUSTIFICATION, - actual=stripped, - suggestion=( - f"Provide justification with at least {_MIN_JUSTIFICATION} " - f"characters (currently {stripped})" - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ) - ) - - elif capability.safety_class == SafetyClass.DESTRUCTIVE: - if "admin" not in roles: - failed.append( - FailedCondition( - condition="roles", - required=["admin"], - actual=sorted(roles), - suggestion=f"Add 'admin' role to principal '{pid}'", - reason_code=str(DenialReason.MISSING_ROLE), - ) - ) - stripped = len(justification.strip()) - if stripped < _MIN_JUSTIFICATION: - failed.append( - FailedCondition( - condition="min_justification", - required=_MIN_JUSTIFICATION, - actual=stripped, - suggestion=( - f"Provide justification with at least {_MIN_JUSTIFICATION} " - f"characters (currently {stripped})" - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ) - ) - - # ── Sensitivity checks ──────────────────────────────────────────────── - - if ( - capability.sensitivity in (SensitivityTag.PII, SensitivityTag.PCI) - and "tenant" not in principal.attributes - ): - failed.append( - FailedCondition( - condition="tenant_attribute", - required="present", - actual="absent", - suggestion=f"Add 'tenant' attribute to principal '{pid}'", - reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), - ) - ) - - if capability.sensitivity == SensitivityTag.SECRETS: - if not (roles & {"admin", "secrets_reader"}): - failed.append( - FailedCondition( - condition="roles", - required=["admin", "secrets_reader"], - actual=sorted(roles), - suggestion=f"Add 'admin' or 'secrets_reader' role to principal '{pid}'", - reason_code=str(DenialReason.MISSING_ROLE), - ) - ) - stripped = len(justification.strip()) - if stripped < _MIN_JUSTIFICATION: - failed.append( - FailedCondition( - condition="min_justification", - required=_MIN_JUSTIFICATION, - actual=stripped, - suggestion=( - f"Provide justification with at least {_MIN_JUSTIFICATION} " - f"characters (currently {stripped})" - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ) - ) - - if capability.sensitivity == SensitivityTag.MEMORY: - memory_scope = str(request.scope.get("memory_scope", "")) if request.scope else "" - is_write = capability.safety_class in ( - SafetyClass.WRITE, - SafetyClass.DESTRUCTIVE, - ) - if is_write and not (roles & {"memory_writer", "admin"}): - failed.append( - FailedCondition( - condition="roles", - required=["memory_writer", "admin"], - actual=sorted(roles), - suggestion=(f"Add 'memory_writer' or 'admin' role to principal '{pid}'"), - reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), - ) - ) - if ( - not is_write - and memory_scope == "sensitive" - and not (roles & {"memory_reader_sensitive", "admin"}) - ): - failed.append( - FailedCondition( - condition="roles", - required=["memory_reader_sensitive", "admin"], - actual=sorted(roles), - suggestion=( - f"Add 'memory_reader_sensitive' or 'admin' role to " - f"principal '{pid}' (or narrow the request scope away " - f"from 'sensitive')" - ), - reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), - ) - ) - + result = self._rule_chain.run( + request, + capability, + principal, + justification=justification, + collect_all=True, + read_only=True, + ) + failed = [failure.condition for failure in result.failures] denied = bool(failed) - remediation = [fc.suggestion for fc in failed] + remediation = [condition.suggestion for condition in failed] if denied: first = failed[0] @@ -633,7 +298,7 @@ def explain( ) narrative = ( f"Request for '{cid}' by '{pid}' would be denied: " - + "; ".join(fc.suggestion for fc in failed) + + "; ".join(condition.suggestion for condition in failed) + "." ) primary_code = first.reason_code diff --git a/src/weaver_kernel/policy_reasons.py b/src/weaver_kernel/policy_reasons.py index 8c00f35..a73ebc2 100644 --- a/src/weaver_kernel/policy_reasons.py +++ b/src/weaver_kernel/policy_reasons.py @@ -54,6 +54,12 @@ class DenialReason(_StrEnumCompat): INVALID_CONSTRAINT = "invalid_constraint" """A constraint value (e.g. ``max_rows``) is not parseable or in range.""" + TTL_EXCEEDED = "ttl_exceeded" + """A requested per-grant token TTL exceeds the policy maximum (#203).""" + + ARG_CONSTRAINT_VIOLATION = "arg_constraint_violation" + """Invocation arguments violated a signed ``constraints["args"]`` rule (#183).""" + # Rate limiting RATE_LIMITED = "rate_limited" """The sliding-window rate limit for this principal/capability was exceeded.""" diff --git a/src/weaver_kernel/policy_ttl.py b/src/weaver_kernel/policy_ttl.py new file mode 100644 index 0000000..4f770e8 --- /dev/null +++ b/src/weaver_kernel/policy_ttl.py @@ -0,0 +1,70 @@ +"""Per-grant TTL validation and resolution (#203). + +Extracted from :mod:`weaver_kernel.policy` to keep that module within the +AGENTS.md 300-line budget (it is already at its ratchet ceiling). The maximum +per-grant token TTL is policy configuration; :class:`DefaultPolicyEngine` stores +the raw value and delegates validation and per-capability resolution here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .enums import SafetyClass +from .errors import AgentKernelError + +if TYPE_CHECKING: # pragma: no cover + from .models import Capability + +MaxTTLConfig = int | dict[SafetyClass, int] | None +"""A single TTL cap, a per-safety-class mapping, or ``None`` for no maximum.""" + + +def validate_max_ttl_s(max_ttl_s: MaxTTLConfig) -> None: + """Reject a non-positive ``max_ttl_s`` configuration. + + Args: + max_ttl_s: The configured maximum TTL (scalar, per-class map, or ``None``). + + Raises: + AgentKernelError: If any configured value is not a positive integer. + """ + if max_ttl_s is None: + return + if isinstance(max_ttl_s, dict): + # Fail closed on non-SafetyClass keys: resolve_max_ttl_s() looks up by + # SafetyClass, so a stray string key (e.g. from config parsing) would be + # silently ignored and the cap never applied. + for key in max_ttl_s: + if not isinstance(key, SafetyClass): + raise AgentKernelError( + f"Invalid max_ttl_s: keys must be SafetyClass members, got {key!r}." + ) + values = list(max_ttl_s.values()) + else: + values = [max_ttl_s] + for value in values: + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise AgentKernelError( + f"Invalid max_ttl_s: values must be positive integers, got {value!r}." + ) + + +def resolve_max_ttl_s(max_ttl_s: MaxTTLConfig, capability: Capability) -> int | None: + """Return the maximum allowed TTL for *capability*, or ``None`` if uncapped. + + Args: + max_ttl_s: The configured maximum TTL (scalar, per-class map, or ``None``). + capability: The capability whose grant TTL is being bounded. + + Returns: + The maximum TTL in seconds, or ``None`` when no cap applies. + """ + if max_ttl_s is None: + return None + if isinstance(max_ttl_s, dict): + return max_ttl_s.get(capability.safety_class) + return max_ttl_s + + +__all__ = ["MaxTTLConfig", "validate_max_ttl_s", "resolve_max_ttl_s"] diff --git a/src/weaver_kernel/rate_limit.py b/src/weaver_kernel/rate_limit.py index b74d664..bbef7cc 100644 --- a/src/weaver_kernel/rate_limit.py +++ b/src/weaver_kernel/rate_limit.py @@ -66,6 +66,22 @@ def check(self, key: str, limit: int, window_seconds: float) -> bool: return True return len(entry.timestamps) < limit + def peek(self, key: str, limit: int, window_seconds: float) -> bool: + """Read-only counterpart to :meth:`check`. + + Returns whether the next invocation would be within the limit without + creating a window, pruning expired timestamps, or otherwise mutating + limiter state. Policy explanation uses this path so explaining a + decision can never consume or rewrite rate-limit budget. + """ + now = self._clock() + cutoff = now - window_seconds + entry = self._windows.get(key) + if entry is None: + return True + active = sum(timestamp > cutoff for timestamp in entry.timestamps) + return active < limit + def record(self, key: str) -> None: """Record an invocation for *key*.""" self._windows[key].timestamps.append(self._clock()) diff --git a/src/weaver_kernel/tokens.py b/src/weaver_kernel/tokens.py index 8b3a450..7665b91 100644 --- a/src/weaver_kernel/tokens.py +++ b/src/weaver_kernel/tokens.py @@ -1,22 +1,22 @@ -"""HMAC-SHA256 token provider for capability authorization.""" +"""Capability tokens: the :class:`CapabilityToken` dataclass and the +:class:`TokenProvider` Protocol. + +The concrete :class:`HMACTokenProvider` lives in +:mod:`weaver_kernel._hmac_provider` (extracted to honour the AGENTS.md +300-line module budget). Import it from :mod:`weaver_kernel` (public) or +:mod:`weaver_kernel._hmac_provider`. It is intentionally *not* re-exported here: +``_hmac_provider`` imports this module, so re-exporting would create an import +cycle (flagged by CodeQL). +""" 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 .stores import InMemoryRevocationStore, RevocationStoreProtocol - -logger = logging.getLogger(__name__) - +from ._token_signing import parse_token_fields # ── Token dataclass ─────────────────────────────────────────────────────────── @@ -38,11 +38,16 @@ class CapabilityToken: constraints: dict[str, Any] = field(default_factory=dict) audit_id: str = "" signature: str = "" + key_id: str = "" # ── Serialization ───────────────────────────────────────────────────────── def _signable_payload(self) -> str: - """Return the canonical JSON string used as the HMAC message.""" + """Return the canonical JSON string used as the HMAC message. + + The signing ``key_id`` is included so a token cannot be re-labelled to + verify against a different rotation key (#185). + """ payload = { "token_id": self.token_id, "capability_id": self.capability_id, @@ -51,6 +56,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 +71,25 @@ 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. + + Args: + data: A serialized token, e.g. from :meth:`to_dict` or an untrusted + transport source. + + Returns: + The reconstructed :class:`CapabilityToken`. + + Raises: + TokenInvalid: If *data* is missing a required field, has a field of + the wrong type, or carries a malformed timestamp (#200). + """ + return cls(**parse_token_fields(data)) # ── Protocol ────────────────────────────────────────────────────────────────── @@ -154,183 +164,4 @@ def revoke_all(self, principal_id: str) -> int: ... -# ── Implementation ──────────────────────────────────────────────────────────── - - -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. - """ - - def __init__( - self, - secret: str | None = None, - *, - revocation_store: RevocationStoreProtocol | None = None, - ) -> None: - self._secret = secret # None → use env / dev fallback at call time - # 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, - audit_id: str = "", - ) -> CapabilityToken: - """Issue a new signed token. - - Args: - capability_id: The capability this token authorises. - principal_id: The principal this token is issued to. - constraints: Optional execution constraints. - ttl_seconds: How long the token is valid (default 1 hour). - audit_id: Audit trail ID to embed in the token. - - Returns: - A freshly signed :class:`CapabilityToken`. - """ - now = datetime.datetime.now(tz=datetime.timezone.utc) - token = CapabilityToken( - token_id=str(uuid.uuid4()), - capability_id=capability_id, - principal_id=principal_id, - issued_at=now, - expires_at=now + datetime.timedelta(seconds=ttl_seconds), - constraints=constraints or {}, - audit_id=audit_id, - ) - token.signature = self._sign(token._signable_payload()) - self._revocation.track(principal_id, token.token_id, token.expires_at) - logger.debug( - "token_issued", - extra={ - "token_id": token.token_id, - "capability_id": capability_id, - "principal_id": principal_id, - "audit_id": audit_id, - "expires_at": token.expires_at.isoformat(), - }, - ) - return token - - def revoke(self, token_id: str) -> None: - """Revoke a single token by ID. - - Idempotent — revoking an already-revoked or unknown token is a no-op. - - Args: - token_id: The ID of the token to revoke. - """ - self._revocation.revoke(token_id) - - def revoke_all(self, principal_id: str) -> int: - """Revoke all tokens issued to a principal. - - Args: - principal_id: The principal whose tokens should be revoked. - - Returns: - The number of tokens newly revoked by this call (excluding tokens - that were already revoked). - """ - return self._revocation.revoke_principal(principal_id) - - def sweep_revocations(self, now: datetime.datetime | None = None) -> int: - """Drop revocation bookkeeping for tokens that have already expired. - - Bounds revocation-state growth in long-lived processes (#182). Safe to - call at any time: an expired token fails the verifier's expiry check - regardless, so sweeping its entry never un-revokes a live token. The - in-memory store also sweeps itself lazily; durable backends expose this - for an operator to call on a schedule. - - Args: - now: Reference time; defaults to the current UTC time. - - Returns: - The number of tracked tokens whose state was removed. - """ - when = now or datetime.datetime.now(tz=datetime.timezone.utc) - return self._revocation.sweep_expired(when) - - def verify( - self, - token: CapabilityToken, - *, - expected_principal_id: str, - expected_capability_id: str, - ) -> None: - """Verify a token's signature, expiry, and scope bindings. - - Args: - token: The token to verify. - expected_principal_id: The principal that should own this token. - expected_capability_id: The capability this token should authorize. - - 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. - 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}'." - ) +__all__ = ["CapabilityToken", "TokenProvider"] diff --git a/tests/test_architecture.py b/tests/test_architecture.py index afd428a..3bd86d9 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -50,14 +50,15 @@ "__init__.py": 341, "models.py": 753, "policy.py": 652, - "kernel/__init__.py": 541, + "kernel/__init__.py": 540, "adapters/_base.py": 459, "kernel/_invoke.py": 390, "firewall/transform.py": 377, "adapters/openai.py": 358, "stores/sqlite.py": 350, - "tokens.py": 336, "federation_discovery.py": 306, + # tokens.py was split into _token_signing.py + _hmac_provider.py (#185) and + # is now well under the 300-line budget, so it is no longer ratcheted. } _LINE_BUDGET = 300 diff --git a/tests/test_kernel.py b/tests/test_kernel.py index b07b49f..ee1ec93 100644 --- a/tests/test_kernel.py +++ b/tests/test_kernel.py @@ -19,6 +19,7 @@ SafetyClass, StaticRouter, TokenExpired, + TokenScopeError, ) from weaver_kernel.drivers.base import ExecutionContext from weaver_kernel.errors import FirewallError @@ -1233,3 +1234,404 @@ def test_kernel_query_traces(kernel: Kernel, reader_principal: Principal) -> Non assert denied[0].capability_id == "billing.delete_invoice" # Filtering by a principal who did nothing yields nothing. assert kernel.query_traces(TraceQuery(principal_id="ghost")) == [] + + +# ── Per-grant TTL (#203) ─────────────────────────────────────────────────────── + + +def _read_req() -> CapabilityRequest: + return CapabilityRequest(capability_id="billing.list_invoices", goal="lookup") + + +def test_grant_ttl_s_sets_token_expiry(kernel: Kernel, reader_principal: Principal) -> None: + grant = kernel.grant_capability(_read_req(), reader_principal, justification="", ttl_s=60) + delta = (grant.token.expires_at - grant.token.issued_at).total_seconds() + assert delta == 60 + + +def test_grant_default_ttl_unchanged(kernel: Kernel, reader_principal: Principal) -> None: + grant = kernel.grant_capability(_read_req(), reader_principal, justification="") + delta = (grant.token.expires_at - grant.token.issued_at).total_seconds() + assert delta == 3600 + + +def test_grant_get_token_threads_ttl(kernel: Kernel, reader_principal: Principal) -> None: + token = kernel.get_token(_read_req(), reader_principal, justification="", ttl_s=120) + assert (token.expires_at - token.issued_at).total_seconds() == 120 + + +@pytest.mark.parametrize("bad_ttl", [0, -5]) +def test_grant_non_positive_ttl_denied( + kernel: Kernel, reader_principal: Principal, bad_ttl: int +) -> None: + with pytest.raises(PolicyDenied) as exc_info: + kernel.grant_capability(_read_req(), reader_principal, justification="", ttl_s=bad_ttl) + assert exc_info.value.reason_code == "invalid_constraint" + + +def _capped_kernel(registry: CapabilityRegistry, memory_driver: InMemoryDriver) -> Kernel: + from weaver_kernel import DefaultPolicyEngine + + router = StaticRouter(routes={"billing.list_invoices": ["memory"]}) + k = Kernel( + registry=registry, + policy=DefaultPolicyEngine(max_ttl_s={SafetyClass.READ: 30}), + token_provider=HMACTokenProvider(secret="test-secret-do-not-use-in-prod"), + router=router, + ) + k.register_driver(memory_driver) + return k + + +def test_grant_ttl_over_max_denied( + registry: CapabilityRegistry, memory_driver: InMemoryDriver, reader_principal: Principal +) -> None: + capped = _capped_kernel(registry, memory_driver) + with pytest.raises(PolicyDenied) as exc_info: + capped.grant_capability(_read_req(), reader_principal, justification="", ttl_s=120) + assert exc_info.value.reason_code == "ttl_exceeded" + + +def test_grant_ttl_within_max_allowed( + registry: CapabilityRegistry, memory_driver: InMemoryDriver, reader_principal: Principal +) -> None: + capped = _capped_kernel(registry, memory_driver) + grant = capped.grant_capability(_read_req(), reader_principal, justification="", ttl_s=30) + assert (grant.token.expires_at - grant.token.issued_at).total_seconds() == 30 + + +def test_grant_ttl_denial_is_audited( + registry: CapabilityRegistry, memory_driver: InMemoryDriver, reader_principal: Principal +) -> None: + capped = _capped_kernel(registry, memory_driver) + with pytest.raises(PolicyDenied): + capped.grant_capability(_read_req(), reader_principal, justification="", ttl_s=999) + traces = capped.list_traces() + assert any(t.event_type == "deny" and t.reason_code == "ttl_exceeded" for t in traces), ( + "TTL denial should be recorded as a deny audit trace" + ) + + +def test_grant_ttl_graceful_when_engine_has_no_max( + registry: CapabilityRegistry, memory_driver: InMemoryDriver, reader_principal: Principal +) -> None: + """A policy engine without ``max_ttl_s`` imposes no cap; ttl_s is still honored.""" + from weaver_kernel.models import PolicyDecision + + class _StubPolicy: + def evaluate(self, request, capability, principal, *, justification): # type: ignore[no-untyped-def] + return PolicyDecision(allowed=True, reason="ok", constraints={}) + + router = StaticRouter(routes={"billing.list_invoices": ["memory"]}) + k = Kernel( + registry=registry, + policy=_StubPolicy(), # type: ignore[arg-type] + token_provider=HMACTokenProvider(secret="test-secret-do-not-use-in-prod"), + router=router, + ) + k.register_driver(memory_driver) + grant = k.grant_capability(_read_req(), reader_principal, justification="", ttl_s=99999) + assert (grant.token.expires_at - grant.token.issued_at).total_seconds() == 99999 + # A non-positive TTL is still rejected regardless of engine capabilities. + with pytest.raises(PolicyDenied): + k.grant_capability(_read_req(), reader_principal, justification="", ttl_s=-1) + + +# ── Signed argument-level constraints at invoke time (#183) ──────────────────── + + +def _req_with_args(spec: dict[str, object]) -> CapabilityRequest: + return CapabilityRequest( + capability_id="billing.list_invoices", goal="lookup", constraints={"args": spec} + ) + + +@pytest.mark.asyncio +async def test_arg_constraint_allowed_keys_violation_denied( + kernel: Kernel, reader_principal: Principal +) -> None: + token = kernel.get_token( + _req_with_args({"allowed_keys": ["operation"]}), reader_principal, justification="" + ) + with pytest.raises(TokenScopeError) as exc_info: + await kernel.invoke( + token, + principal=reader_principal, + args={"operation": "billing.list_invoices", "leak": "x"}, + ) + assert exc_info.value.reason_code == "arg_constraint_violation" + + +@pytest.mark.asyncio +async def test_arg_constraint_allowed_keys_compliant_passes( + kernel: Kernel, reader_principal: Principal +) -> None: + token = kernel.get_token( + _req_with_args({"allowed_keys": ["operation"]}), reader_principal, justification="" + ) + frame = await kernel.invoke( + token, principal=reader_principal, args={"operation": "billing.list_invoices"} + ) + assert frame.action_id != "" + + +@pytest.mark.asyncio +async def test_arg_constraint_pinned_mismatch_denied( + kernel: Kernel, reader_principal: Principal +) -> None: + token = kernel.get_token( + _req_with_args({"pinned": {"customer_id": "c123"}}), reader_principal, justification="" + ) + with pytest.raises(TokenScopeError, match="pinned"): + await kernel.invoke( + token, + principal=reader_principal, + args={"operation": "billing.list_invoices", "customer_id": "c999"}, + ) + + +@pytest.mark.asyncio +async def test_arg_constraint_pinned_missing_key_fails_closed( + kernel: Kernel, reader_principal: Principal +) -> None: + token = kernel.get_token( + _req_with_args({"pinned": {"customer_id": "c123"}}), reader_principal, justification="" + ) + with pytest.raises(TokenScopeError): + await kernel.invoke( + token, principal=reader_principal, args={"operation": "billing.list_invoices"} + ) + + +@pytest.mark.asyncio +async def test_arg_constraint_prefix_violation_denied( + kernel: Kernel, reader_principal: Principal +) -> None: + token = kernel.get_token( + _req_with_args({"prefix": {"path": "/safe/"}}), reader_principal, justification="" + ) + with pytest.raises(TokenScopeError, match="starting with"): + await kernel.invoke( + token, + principal=reader_principal, + args={"operation": "billing.list_invoices", "path": "/etc/passwd"}, + ) + + +@pytest.mark.asyncio +async def test_arg_constraint_prefix_compliant_passes( + kernel: Kernel, reader_principal: Principal +) -> None: + token = kernel.get_token( + _req_with_args({"prefix": {"path": "/safe/"}}), reader_principal, justification="" + ) + frame = await kernel.invoke( + token, + principal=reader_principal, + args={"operation": "billing.list_invoices", "path": "/safe/report.csv"}, + ) + assert frame.action_id != "" + + +@pytest.mark.asyncio +async def test_arg_constraint_dry_run_parity(kernel: Kernel, reader_principal: Principal) -> None: + """Dry-run raises the same TokenScopeError a real invoke would (#183).""" + token = kernel.get_token( + _req_with_args({"allowed_keys": ["operation"]}), reader_principal, justification="" + ) + with pytest.raises(TokenScopeError): + await kernel.invoke( + token, + principal=reader_principal, + args={"operation": "billing.list_invoices", "leak": "x"}, + dry_run=True, + ) + + +@pytest.mark.asyncio +async def test_arg_constraint_violation_records_failure_trace( + kernel: Kernel, reader_principal: Principal +) -> None: + """A denied invoke is audited (I-02) with no driver reached; dry-run is not.""" + token = kernel.get_token( + _req_with_args({"allowed_keys": ["operation"]}), reader_principal, justification="" + ) + with pytest.raises(TokenScopeError): + await kernel.invoke( + token, + principal=reader_principal, + args={"operation": "billing.list_invoices", "leak": "x"}, + ) + failures = [t for t in kernel.list_traces() if t.error and t.driver_id == ""] + assert failures and "allowed_keys" in failures[-1].error + + +# ── Per-invocation rate limiting (#170) ──────────────────────────────────────── + + +def _rate_limited_kernel( + registry: CapabilityRegistry, + memory_driver: InMemoryDriver, + limits: dict[SafetyClass, tuple[int, float]], + clock, # type: ignore[no-untyped-def] +) -> Kernel: + router = StaticRouter(routes={"billing.list_invoices": ["memory"]}) + k = Kernel( + registry=registry, + token_provider=HMACTokenProvider(secret="test-secret-do-not-use-in-prod"), + router=router, + invoke_rate_limits=limits, + invoke_rate_clock=clock, + ) + k.register_driver(memory_driver) + return k + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_blocks_over_limit( + registry: CapabilityRegistry, memory_driver: InMemoryDriver, reader_principal: Principal +) -> None: + now = [1000.0] + k = _rate_limited_kernel( + registry, memory_driver, {SafetyClass.READ: (2, 60.0)}, lambda: now[0] + ) + token = k.get_token(_read_req(), reader_principal, justification="") + args = {"operation": "billing.list_invoices"} + await k.invoke(token, principal=reader_principal, args=args) + await k.invoke(token, principal=reader_principal, args=args) + with pytest.raises(PolicyDenied) as exc_info: + await k.invoke(token, principal=reader_principal, args=args) + assert exc_info.value.reason_code == "rate_limited" + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_default_off(kernel: Kernel, reader_principal: Principal) -> None: + token = kernel.get_token(_read_req(), reader_principal, justification="") + args = {"operation": "billing.list_invoices"} + for _ in range(20): + await kernel.invoke(token, principal=reader_principal, args=args) + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_window_reset( + registry: CapabilityRegistry, memory_driver: InMemoryDriver, reader_principal: Principal +) -> None: + now = [1000.0] + k = _rate_limited_kernel( + registry, memory_driver, {SafetyClass.READ: (1, 60.0)}, lambda: now[0] + ) + token = k.get_token(_read_req(), reader_principal, justification="") + args = {"operation": "billing.list_invoices"} + await k.invoke(token, principal=reader_principal, args=args) + with pytest.raises(PolicyDenied): + await k.invoke(token, principal=reader_principal, args=args) + now[0] += 61.0 # slide past the window + await k.invoke(token, principal=reader_principal, args=args) + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_is_per_principal( + registry: CapabilityRegistry, + memory_driver: InMemoryDriver, + reader_principal: Principal, + service_principal: Principal, +) -> None: + now = [1000.0] + k = _rate_limited_kernel( + registry, memory_driver, {SafetyClass.READ: (1, 60.0)}, lambda: now[0] + ) + args = {"operation": "billing.list_invoices"} + t1 = k.get_token(_read_req(), reader_principal, justification="") + t2 = k.get_token(_read_req(), service_principal, justification="") + await k.invoke(t1, principal=reader_principal, args=args) + # A different principal has an independent window. + await k.invoke(t2, principal=service_principal, args=args) + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_dry_run_does_not_consume( + registry: CapabilityRegistry, memory_driver: InMemoryDriver, reader_principal: Principal +) -> None: + now = [1000.0] + k = _rate_limited_kernel( + registry, memory_driver, {SafetyClass.READ: (1, 60.0)}, lambda: now[0] + ) + token = k.get_token(_read_req(), reader_principal, justification="") + args = {"operation": "billing.list_invoices"} + # Many dry-runs consume nothing... + for _ in range(5): + await k.invoke(token, principal=reader_principal, args=args, dry_run=True) + # ...so a real invoke still fits within the limit of 1. + await k.invoke(token, principal=reader_principal, args=args) + + +@pytest.mark.asyncio +async def test_invoke_rate_limit_concurrent_calls_do_not_exceed_limit( + registry: CapabilityRegistry, memory_driver: InMemoryDriver, reader_principal: Principal +) -> None: + now = [1000.0] + k = _rate_limited_kernel( + registry, memory_driver, {SafetyClass.READ: (3, 60.0)}, lambda: now[0] + ) + token = k.get_token(_read_req(), reader_principal, justification="") + args = {"operation": "billing.list_invoices"} + results = await asyncio.gather( + *(k.invoke(token, principal=reader_principal, args=args) for _ in range(10)), + return_exceptions=True, + ) + admitted = sum(1 for r in results if not isinstance(r, Exception)) + denied = sum(1 for r in results if isinstance(r, PolicyDenied)) + assert admitted == 3 + assert denied == 7 + + +def test_invalid_invoke_rate_limits_rejected_at_construction( + registry: CapabilityRegistry, +) -> None: + from weaver_kernel import AgentKernelError + + with pytest.raises(AgentKernelError, match="invoke_rate_limits"): + Kernel(registry=registry, invoke_rate_limits={SafetyClass.READ: (0, 60.0)}) + + +@pytest.mark.asyncio +async def test_malformed_args_constraint_denied( + kernel: Kernel, reader_principal: Principal +) -> None: + token = kernel.get_token( + _req_with_args("not-a-dict"), # type: ignore[arg-type] + reader_principal, + justification="", + ) + with pytest.raises(TokenScopeError, match="malformed 'args' constraint"): + await kernel.invoke( + token, principal=reader_principal, args={"operation": "billing.list_invoices"} + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "spec,needle", + [ + ({"allowed_keys": 5}, "allowed_keys"), + # An unhashable element would blow up set(allowed_keys) with a TypeError. + ({"allowed_keys": ["operation", ["nested"]]}, "allowed_keys"), + ({"pinned": ["not", "a", "dict"]}, "pinned"), + ({"prefix": ["not", "a", "dict"]}, "prefix"), + # A non-string prefix would blow up value.startswith() with a TypeError. + ({"prefix": {"path": 123}}, "prefix"), + ], +) +async def test_malformed_arg_constraint_nested_type_fails_closed( + kernel: Kernel, reader_principal: Principal, spec: dict, needle: str +) -> None: + """A malformed nested arg-constraint denies (fail closed), never an untyped crash.""" + token = kernel.get_token(_req_with_args(spec), reader_principal, justification="") + with pytest.raises(TokenScopeError) as exc_info: + await kernel.invoke( + token, principal=reader_principal, args={"operation": "billing.list_invoices"} + ) + assert exc_info.value.reason_code == "arg_constraint_violation" + assert needle in str(exc_info.value) + # And it is audited (I-02), reached no driver. + assert any(t.error and t.driver_id == "" for t in kernel.list_traces()) diff --git a/tests/test_mcp_discovery_safety.py b/tests/test_mcp_discovery_safety.py new file mode 100644 index 0000000..988b2df --- /dev/null +++ b/tests/test_mcp_discovery_safety.py @@ -0,0 +1,145 @@ +"""Security-focused MCP discovery classification tests (#181).""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from types import SimpleNamespace +from typing import Any + +import pytest + +from weaver_kernel import DriverError, MCPDriver, SafetyClass + + +class _Session: + def __init__(self, tools: list[Any]) -> None: + self._tools = tools + + async def list_tools(self, cursor: str | None = None) -> Any: + return SimpleNamespace(tools=self._tools, nextCursor=None) + + +def _factory(tools: list[Any]) -> Any: + @asynccontextmanager + async def factory() -> AsyncIterator[_Session]: + yield _Session(tools) + + return factory + + +def _tool( + name: str, + *, + read_only: bool = False, + destructive: bool = False, +) -> Any: + annotations = None + if read_only or destructive: + annotations = SimpleNamespace( + readOnlyHint=read_only, + destructiveHint=destructive, + idempotentHint=False, + ) + return SimpleNamespace( + name=name, + description=f"{name} tool", + annotations=annotations, + outputSchema=None, + ) + + +def _driver(*tools: Any) -> MCPDriver: + return MCPDriver( + driver_id="mcp:test", + session_factory=_factory(list(tools)), + server_name="test", + transport="stdio", + ) + + +@pytest.mark.asyncio +async def test_unannotated_tools_are_rejected_by_default() -> None: + driver = _driver(_tool("list_files"), _tool("delete_repo")) + + with pytest.raises(DriverError) as exc_info: + await driver.discover(namespace="repo") + + message = str(exc_info.value) + assert "delete_repo" in message + assert "list_files" in message + assert "missing metadata is not treated as READ authority" in message + + +@pytest.mark.asyncio +async def test_explicit_safety_map_classifies_unannotated_tool() -> None: + driver = _driver(_tool("list_files")) + + capabilities = await driver.discover( + namespace="repo", + safety_class_map={"list_files": SafetyClass.READ}, + ) + + assert len(capabilities) == 1 + assert capabilities[0].capability_id == "repo.list_files" + assert capabilities[0].safety_class is SafetyClass.READ + + +@pytest.mark.asyncio +async def test_malformed_explicit_safety_map_fails_closed() -> None: + driver = _driver(_tool("delete_repo")) + + with pytest.raises(DriverError, match=r"safety_class_map\['delete_repo'\]"): + await driver.discover( + safety_class_map={"delete_repo": "READ"}, # type: ignore[dict-item] + ) + + +@pytest.mark.asyncio +async def test_explicit_unannotated_fallback_warns_and_names_tools( + caplog: pytest.LogCaptureFixture, +) -> None: + driver = _driver(_tool("send_email"), _tool("write_file")) + + with caplog.at_level(logging.WARNING, logger="weaver_kernel.drivers.mcp"): + capabilities = await driver.discover(unannotated_safety=SafetyClass.WRITE) + + assert {cap.safety_class for cap in capabilities} == {SafetyClass.WRITE} + assert "mcp_unannotated_safety_fallback" in caplog.text + assert "send_email" in caplog.text + assert "write_file" in caplog.text + + +@pytest.mark.asyncio +async def test_explicit_mcp_hints_still_infer_safety_class() -> None: + driver = _driver( + _tool("list_files", read_only=True), + _tool("delete_repo", destructive=True), + ) + + capabilities = await driver.discover() + by_name = {cap.name: cap.safety_class for cap in capabilities} + + assert by_name == { + "list_files": SafetyClass.READ, + "delete_repo": SafetyClass.DESTRUCTIVE, + } + + +@pytest.mark.asyncio +async def test_conflicting_hints_prefers_destructive() -> None: + driver = _driver(_tool("delete_repo", read_only=True, destructive=True)) + + capabilities = await driver.discover() + assert len(capabilities) == 1 + assert capabilities[0].name == "delete_repo" + assert capabilities[0].safety_class is SafetyClass.DESTRUCTIVE + + +@pytest.mark.asyncio +async def test_invalid_unannotated_safety_is_rejected() -> None: + driver = _driver(_tool("list_files")) + + with pytest.raises(DriverError, match="unannotated_safety"): + await driver.discover(unannotated_safety="READ") # type: ignore[arg-type] diff --git a/tests/test_mcp_driver.py b/tests/test_mcp_driver.py index 53097d3..69e434d 100644 --- a/tests/test_mcp_driver.py +++ b/tests/test_mcp_driver.py @@ -134,7 +134,11 @@ async def test_discover_converts_tools_to_capabilities() -> None: ) capabilities = await driver.discover( - namespace="fs", safety_class_map={"write_file": SafetyClass.WRITE} + namespace="fs", + safety_class_map={ + "list_files": SafetyClass.READ, + "write_file": SafetyClass.WRITE, + }, ) assert [cap.capability_id for cap in capabilities] == [ @@ -265,7 +269,7 @@ async def test_kernel_pipeline_with_discover_register_grant_invoke() -> None: transport="stdio", ) - capabilities = await driver.discover() + capabilities = await driver.discover(safety_class_map={"math.sum": SafetyClass.READ}) registry = CapabilityRegistry() registry.register_many(capabilities) @@ -313,7 +317,10 @@ async def in_memory_factory() -> AsyncIterator[ClientSession]: transport="stdio", ) - capabilities = await driver.discover(namespace="math") + capabilities = await driver.discover( + namespace="math", + safety_class_map={"add": SafetyClass.READ}, + ) assert any(cap.capability_id == "math.add" for cap in capabilities) add_cap = next(c for c in capabilities if c.capability_id == "math.add") assert add_cap.impl is not None diff --git a/tests/test_multi_worker_consistency.py b/tests/test_multi_worker_consistency.py new file mode 100644 index 0000000..30aab02 --- /dev/null +++ b/tests/test_multi_worker_consistency.py @@ -0,0 +1,143 @@ +"""Executable documentation for current process-local consistency semantics (#226).""" + +from __future__ import annotations + +import json +import subprocess +import sys +from typing import Any + +import pytest + +from weaver_kernel import HandleStore, HMACTokenProvider, TokenRevoked +from weaver_kernel.rate_limit import RateLimiter + +_SECRET = "multi-worker-consistency-test-secret" + + +def _run_fresh_python(source: str, payload: dict[str, Any]) -> str: + """Run a probe in a separate Python process and return its stdout.""" + completed = subprocess.run( + [sys.executable, "-c", source], + input=json.dumps(payload), + text=True, + capture_output=True, + check=True, + ) + return completed.stdout.strip() + + +def test_token_signature_verifies_across_processes_that_share_a_secret() -> None: + worker_a = HMACTokenProvider(secret=_SECRET) + token = worker_a.issue("tickets.read", "alice") + + result = _run_fresh_python( + """ +import json +import sys +from weaver_kernel import CapabilityToken, HMACTokenProvider + +payload = json.load(sys.stdin) +token = CapabilityToken.from_dict(payload["token"]) +provider = HMACTokenProvider(secret=payload["secret"]) +provider.verify( + token, + expected_principal_id="alice", + expected_capability_id="tickets.read", +) +print("verified") +""", + {"secret": _SECRET, "token": token.to_dict()}, + ) + + assert result == "verified" + + +def test_in_memory_revocation_does_not_propagate_to_fresh_process() -> None: + worker_a = HMACTokenProvider(secret=_SECRET) + token = worker_a.issue("tickets.read", "alice") + worker_a.revoke(token.token_id) + + with pytest.raises(TokenRevoked): + worker_a.verify( + token, + expected_principal_id="alice", + expected_capability_id="tickets.read", + ) + + result = _run_fresh_python( + """ +import json +import sys +from weaver_kernel import CapabilityToken, HMACTokenProvider + +payload = json.load(sys.stdin) +token = CapabilityToken.from_dict(payload["token"]) +provider = HMACTokenProvider(secret=payload["secret"]) +provider.verify( + token, + expected_principal_id="alice", + expected_capability_id="tickets.read", +) +print("verified") +""", + {"secret": _SECRET, "token": token.to_dict()}, + ) + + assert result == "verified" + + +def test_rate_limit_windows_are_process_local() -> None: + def fixed_clock() -> float: + return 100.0 + + worker_a = RateLimiter(clock=fixed_clock) + key = "alice:tickets.read" + + assert worker_a.check(key, limit=1, window_seconds=60.0) + worker_a.record(key) + assert not worker_a.check(key, limit=1, window_seconds=60.0) + + result = _run_fresh_python( + """ +import json +import sys +from weaver_kernel.rate_limit import RateLimiter + +payload = json.load(sys.stdin) +limiter = RateLimiter(clock=lambda: 100.0) +print("allowed" if limiter.check(payload["key"], limit=1, window_seconds=60.0) else "blocked") +""", + {"key": key}, + ) + + assert result == "allowed" + + +def test_default_handle_store_is_not_portable_to_fresh_process() -> None: + worker_a = HandleStore() + handle = worker_a.store( + "tickets.read", + [{"id": 1, "title": "Example"}], + principal_id="alice", + ) + assert worker_a.get(handle.handle_id) == [{"id": 1, "title": "Example"}] + + result = _run_fresh_python( + """ +import json +import sys +from weaver_kernel import HandleNotFound, HandleStore + +payload = json.load(sys.stdin) +try: + HandleStore().get(payload["handle_id"]) +except HandleNotFound: + print("missing") +else: + print("found") +""", + {"handle_id": handle.handle_id}, + ) + + assert result == "missing" diff --git a/tests/test_policy.py b/tests/test_policy.py index 8cdaed6..218d85e 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -1835,3 +1835,43 @@ def test_policy_denied_default_reason_code_is_none() -> None: def test_policy_denied_carries_reason_code() -> None: err = PolicyDenied("msg", reason_code=DenialReason.MISSING_ROLE) assert err.reason_code == DenialReason.MISSING_ROLE + + +# ── Per-grant TTL configuration (#203) ───────────────────────────────────────── + + +def test_max_ttl_s_scalar_resolves_for_all_classes() -> None: + from weaver_kernel.policy_ttl import resolve_max_ttl_s + + engine = DefaultPolicyEngine(max_ttl_s=300) + assert engine.max_ttl_s == 300 + assert resolve_max_ttl_s(engine.max_ttl_s, _cap("c", SafetyClass.READ)) == 300 + assert resolve_max_ttl_s(engine.max_ttl_s, _cap("c", SafetyClass.DESTRUCTIVE)) == 300 + + +def test_max_ttl_s_per_class_map_resolves_and_defaults_to_none() -> None: + from weaver_kernel.policy_ttl import resolve_max_ttl_s + + engine = DefaultPolicyEngine(max_ttl_s={SafetyClass.READ: 60}) + assert resolve_max_ttl_s(engine.max_ttl_s, _cap("c", SafetyClass.READ)) == 60 + # A class absent from the map is uncapped. + assert resolve_max_ttl_s(engine.max_ttl_s, _cap("c", SafetyClass.WRITE)) is None + + +def test_max_ttl_s_none_is_uncapped() -> None: + from weaver_kernel.policy_ttl import resolve_max_ttl_s + + engine = DefaultPolicyEngine() + assert engine.max_ttl_s is None + assert resolve_max_ttl_s(engine.max_ttl_s, _cap("c", SafetyClass.READ)) is None + + +@pytest.mark.parametrize("bad", [0, -1, {SafetyClass.READ: 0}, {SafetyClass.WRITE: -5}, True]) +def test_max_ttl_s_non_positive_rejected_at_construction(bad: object) -> None: + with pytest.raises(AgentKernelError, match="max_ttl_s"): + DefaultPolicyEngine(max_ttl_s=bad) # type: ignore[arg-type] + + +def test_max_ttl_s_non_safetyclass_key_rejected() -> None: + with pytest.raises(AgentKernelError, match="keys must be SafetyClass"): + DefaultPolicyEngine(max_ttl_s={"read": 60}) # type: ignore[dict-item] diff --git a/tests/test_policy_properties.py b/tests/test_policy_properties.py index 63a26aa..51e6914 100644 --- a/tests/test_policy_properties.py +++ b/tests/test_policy_properties.py @@ -62,7 +62,10 @@ TokenScopeError, export_action_traces, ) -from weaver_kernel.policy import _MAX_ROWS_SERVICE, _MAX_ROWS_USER +from weaver_kernel.default_policy_rule_types import ( + MAX_ROWS_SERVICE, + MAX_ROWS_USER, +) # ── Shared strategies & helpers ───────────────────────────────────────────── @@ -207,7 +210,7 @@ def test_max_rows_never_exceeds_policy_cap( capability_id=capability.capability_id, goal="g", constraints=constraints ) decision = engine.evaluate(request, capability, principal, justification="") - cap_limit = _MAX_ROWS_SERVICE if "service" in principal.roles else _MAX_ROWS_USER + cap_limit = MAX_ROWS_SERVICE if "service" in principal.roles else MAX_ROWS_USER capped = decision.constraints["max_rows"] assert 0 <= capped <= cap_limit if requested_max_rows is not None and requested_max_rows >= 0: diff --git a/tests/test_policy_rule_chain.py b/tests/test_policy_rule_chain.py new file mode 100644 index 0000000..b8c08e7 --- /dev/null +++ b/tests/test_policy_rule_chain.py @@ -0,0 +1,261 @@ +"""Agreement and read-only invariants for DefaultPolicyEngine's shared rule chain.""" + +from __future__ import annotations + +from copy import deepcopy + +import pytest + +from weaver_kernel import ( + Capability, + DefaultPolicyEngine, + PolicyDenied, + Principal, + SafetyClass, + SensitivityTag, +) +from weaver_kernel.models import CapabilityRequest +from weaver_kernel.policy_reasons import DenialReason + + +def _cap( + safety: SafetyClass, + *, + sensitivity: SensitivityTag = SensitivityTag.NONE, + allowed_fields: list[str] | None = None, +) -> Capability: + return Capability( + capability_id="cap.test", + name="test", + description="test capability", + safety_class=safety, + sensitivity=sensitivity, + allowed_fields=allowed_fields or [], + ) + + +def _request( + *, max_rows: object | None = None, memory_scope: str | None = None +) -> CapabilityRequest: + constraints = {} if max_rows is None else {"max_rows": max_rows} + scope = {} if memory_scope is None else {"memory_scope": memory_scope} + return CapabilityRequest( + capability_id="cap.test", + goal="test", + constraints=constraints, + scope=scope, + ) + + +_CASES = [ + pytest.param( + _request(), + _cap(SafetyClass.READ), + Principal(principal_id="reader"), + "", + False, + None, + id="read-allowed", + ), + pytest.param( + _request(), + _cap(SafetyClass.WRITE), + Principal(principal_id="no-writer", roles=["reader"]), + "long enough justification", + True, + str(DenialReason.MISSING_ROLE), + id="write-role", + ), + pytest.param( + _request(), + _cap(SafetyClass.WRITE), + Principal(principal_id="writer", roles=["writer"]), + "short", + True, + str(DenialReason.INSUFFICIENT_JUSTIFICATION), + id="write-justification", + ), + pytest.param( + _request(), + _cap(SafetyClass.DESTRUCTIVE), + Principal(principal_id="not-admin", roles=["writer"]), + "long enough justification", + True, + str(DenialReason.MISSING_ROLE), + id="destructive-role", + ), + pytest.param( + _request(), + _cap(SafetyClass.READ, sensitivity=SensitivityTag.PII), + Principal(principal_id="pii"), + "", + True, + str(DenialReason.MISSING_TENANT_ATTRIBUTE), + id="pii-tenant", + ), + pytest.param( + _request(), + _cap(SafetyClass.READ, sensitivity=SensitivityTag.SECRETS), + Principal(principal_id="secret-reader", roles=["reader"]), + "long enough justification", + True, + str(DenialReason.MISSING_ROLE), + id="secrets-role", + ), + pytest.param( + _request(), + _cap(SafetyClass.WRITE, sensitivity=SensitivityTag.MEMORY), + Principal(principal_id="memory-writer", roles=["writer"]), + "long enough justification", + True, + str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), + id="memory-write-role", + ), + pytest.param( + _request(memory_scope="sensitive"), + _cap(SafetyClass.READ, sensitivity=SensitivityTag.MEMORY), + Principal(principal_id="memory-reader", roles=["reader"]), + "", + True, + str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), + id="memory-sensitive-read-role", + ), + pytest.param( + _request(max_rows="not-an-int"), + _cap(SafetyClass.READ), + Principal(principal_id="invalid-constraint"), + "", + True, + str(DenialReason.INVALID_CONSTRAINT), + id="invalid-max-rows", + ), + pytest.param( + _request(max_rows=9999), + _cap(SafetyClass.READ, sensitivity=SensitivityTag.PII, allowed_fields=["id"]), + Principal(principal_id="service", roles=["service"], attributes={"tenant": "acme"}), + "", + False, + None, + id="allowed-with-constraints", + ), +] + + +@pytest.mark.parametrize( + ( + "cap_request", + "capability", + "principal", + "justification", + "denied", + "reason_code", + ), + _CASES, +) +def test_explain_prediction_matches_evaluate( + cap_request: CapabilityRequest, + capability: Capability, + principal: Principal, + justification: str, + denied: bool, + reason_code: str | None, +) -> None: + engine = DefaultPolicyEngine() + + explanation = engine.explain( + cap_request, + capability, + principal, + justification=justification, + ) + + try: + decision = engine.evaluate( + cap_request, + capability, + principal, + justification=justification, + ) + except PolicyDenied as exc: + evaluated_denied = True + evaluated_reason = exc.reason_code + else: + evaluated_denied = not decision.allowed + evaluated_reason = decision.reason_code if evaluated_denied else None + + assert explanation.denied is denied + assert evaluated_denied is denied + assert explanation.denied == evaluated_denied + assert explanation.reason_code == reason_code + assert evaluated_reason == reason_code + + +def _limiter_state(engine: DefaultPolicyEngine) -> dict[str, list[float]]: + return { + key: list(entry.timestamps) + for key, entry in engine._limiter._windows.items() # noqa: SLF001 - invariant test + } + + +def test_explain_rate_limit_path_is_strictly_read_only() -> None: + now = [100.0] + engine = DefaultPolicyEngine( + rate_limits={SafetyClass.READ: (1, 60.0)}, + clock=lambda: now[0], + ) + request = _request() + capability = _cap(SafetyClass.READ) + principal = Principal(principal_id="rate-user") + + first = engine.evaluate(request, capability, principal, justification="") + assert first.allowed is True + before = deepcopy(_limiter_state(engine)) + + explanation = engine.explain(request, capability, principal, justification="") + + assert explanation.denied is True + assert explanation.reason_code == str(DenialReason.RATE_LIMITED) + assert _limiter_state(engine) == before + with pytest.raises(PolicyDenied) as excinfo: + engine.evaluate(request, capability, principal, justification="") + assert excinfo.value.reason_code == str(DenialReason.RATE_LIMITED) + + +def test_explain_does_not_prune_expired_rate_entries() -> None: + now = [100.0] + engine = DefaultPolicyEngine( + rate_limits={SafetyClass.READ: (1, 60.0)}, + clock=lambda: now[0], + ) + request = _request() + capability = _cap(SafetyClass.READ) + principal = Principal(principal_id="rate-user") + engine.evaluate(request, capability, principal, justification="") + now[0] = 161.0 + before = deepcopy(_limiter_state(engine)) + + explanation = engine.explain(request, capability, principal, justification="") + + assert explanation.denied is False + assert _limiter_state(engine) == before + assert engine.evaluate(request, capability, principal, justification="").allowed is True + + +def test_explain_collects_all_failures_while_evaluate_short_circuits() -> None: + engine = DefaultPolicyEngine() + request = _request(max_rows="bad") + capability = _cap(SafetyClass.WRITE, sensitivity=SensitivityTag.PII) + principal = Principal(principal_id="many-failures", roles=["reader"]) + + explanation = engine.explain(request, capability, principal, justification="short") + + assert explanation.denied is True + assert [failure.condition for failure in explanation.failed_conditions] == [ + "roles", + "min_justification", + "tenant_attribute", + "max_rows", + ] + with pytest.raises(PolicyDenied) as excinfo: + engine.evaluate(request, capability, principal, justification="short") + assert excinfo.value.reason_code == str(DenialReason.MISSING_ROLE) diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..0d56db8 --- /dev/null +++ b/tests/test_secrets.py @@ -0,0 +1,56 @@ +"""Tests for HMAC secret resolution and the first-run development warning.""" + +from __future__ import annotations + +import logging + +import weaver_kernel._secrets as secret_module + + +def test_missing_secret_warns_once_with_consequence_and_fix( + monkeypatch, + caplog, +) -> None: + monkeypatch.delenv(secret_module.SECRET_ENV_VAR, raising=False) + monkeypatch.setattr(secret_module, "_DEV_SECRET", None) + + with caplog.at_level(logging.WARNING, logger="weaver_kernel._secrets"): + first = secret_module.resolve_hmac_secret() + second = secret_module.resolve_hmac_secret() + + assert first == second + messages = [ + record.getMessage() + for record in caplog.records + if secret_module.SECRET_ENV_VAR in record.getMessage() + and "process-local random development secret" in record.getMessage() + ] + assert len(messages) == 1 + message = messages[0] + assert secret_module.SECRET_ENV_VAR in message + assert "process-local random development secret" in message + assert "invalid after restart" in message + assert "another process" in message + assert secret_module.PRODUCTION_CHECKLIST_PATH in message + + +def test_environment_secret_avoids_development_warning(monkeypatch, caplog) -> None: + monkeypatch.setenv(secret_module.SECRET_ENV_VAR, "explicit-test-secret") + monkeypatch.setattr(secret_module, "_DEV_SECRET", None) + + with caplog.at_level(logging.WARNING, logger="weaver_kernel._secrets"): + resolved = secret_module.resolve_hmac_secret() + + assert resolved == "explicit-test-secret" + assert caplog.records == [] + + +def test_explicit_secret_takes_precedence_without_warning(monkeypatch, caplog) -> None: + monkeypatch.delenv(secret_module.SECRET_ENV_VAR, raising=False) + monkeypatch.setattr(secret_module, "_DEV_SECRET", None) + + with caplog.at_level(logging.WARNING, logger="weaver_kernel._secrets"): + resolved = secret_module.resolve_hmac_secret("constructor-secret") + + assert resolved == "constructor-secret" + assert caplog.records == [] diff --git a/tests/test_tokens.py b/tests/test_tokens.py index d02600e..988ae22 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -3,10 +3,13 @@ from __future__ import annotations import datetime +from dataclasses import replace import pytest from weaver_kernel import ( + AgentKernelError, + CapabilityToken, HMACTokenProvider, TokenExpired, TokenInvalid, @@ -241,3 +244,210 @@ 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_single_secret_uses_default_key_id() -> None: + """Legacy single-secret config files the key under the 'default' key id.""" + provider = HMACTokenProvider(secret="s1") + token = provider.issue("cap.x", "user-1") + assert token.key_id == "default" + provider.verify(token, expected_principal_id="user-1", expected_capability_id="cap.x") + + +def test_rotation_overlap_window_verifies_previous_key() -> None: + """A token signed under a retired key still verifies while both keys are present.""" + old = HMACTokenProvider(secrets={"k1": "s1"}, active_key_id="k1") + token = old.issue("cap.x", "user-1") + assert token.key_id == "k1" + # Operator rotates: new active key k2, but k1 kept for the overlap window. + rotated = HMACTokenProvider(secrets={"k1": "s1", "k2": "s2"}, active_key_id="k2") + rotated.verify(token, expected_principal_id="user-1", expected_capability_id="cap.x") + # New tokens are signed under the active key. + assert rotated.issue("cap.x", "user-1").key_id == "k2" + + +def test_unknown_key_id_fails_closed() -> None: + """A token whose key id is not in the verifier's keyring fails as TokenInvalid.""" + issuer = HMACTokenProvider(secrets={"k1": "s1"}, active_key_id="k1") + token = issuer.issue("cap.x", "user-1") + # k1 was retired entirely — only k2 remains. + verifier = HMACTokenProvider(secrets={"k2": "s2"}, active_key_id="k2") + with pytest.raises(TokenInvalid, match="unknown key id"): + verifier.verify(token, expected_principal_id="user-1", expected_capability_id="cap.x") + + +def test_key_id_is_signed_tamper_evident() -> None: + """Re-labelling a token's key_id to a present key breaks the signature.""" + provider = HMACTokenProvider(secrets={"k1": "s1", "k2": "s2"}, active_key_id="k1") + token = provider.issue("cap.x", "user-1") + relabelled = replace(token, key_id="k2") + with pytest.raises(TokenInvalid, match="invalid signature"): + provider.verify(relabelled, expected_principal_id="user-1", expected_capability_id="cap.x") + + +def test_non_active_key_verification_logs_key_id_not_secret(caplog) -> None: # type: ignore[no-untyped-def] + """Verifying a non-active-key token logs the key id (never the secret).""" + provider = HMACTokenProvider(secrets={"k1": "s1", "k2": "s2"}, active_key_id="k2") + token = replace( + HMACTokenProvider(secrets={"k1": "s1"}, active_key_id="k1").issue("cap.x", "u1") + ) + with caplog.at_level("INFO"): + provider.verify(token, expected_principal_id="u1", expected_capability_id="cap.x") + records = [r for r in caplog.records if r.message == "token_verified_non_active_key"] + assert records and getattr(records[0], "key_id", None) == "k1" + assert "s1" not in caplog.text and "s2" not in caplog.text + + +def test_secret_and_secrets_together_is_rejected() -> None: + with pytest.raises(AgentKernelError, match="either 'secret' or 'secrets'"): + HMACTokenProvider(secret="s", secrets={"k1": "s1"}) + + +def test_empty_keyring_is_rejected() -> None: + with pytest.raises(AgentKernelError, match="empty"): + HMACTokenProvider(secrets={}) + + +def test_multi_key_without_active_key_id_is_rejected() -> None: + with pytest.raises(AgentKernelError, match="active key id must be specified"): + HMACTokenProvider(secrets={"k1": "s1", "k2": "s2"}) + + +def test_active_key_id_absent_from_keyring_is_rejected() -> None: + with pytest.raises(AgentKernelError, match="not present"): + HMACTokenProvider(secrets={"k1": "s1"}, active_key_id="k9") + + +def test_env_secrets_json_used_when_no_explicit_config(monkeypatch) -> None: # type: ignore[no-untyped-def] + monkeypatch.setenv("WEAVER_KERNEL_SECRETS", '{"k1": "s1", "k2": "s2"}') + monkeypatch.setenv("WEAVER_KERNEL_ACTIVE_KEY", "k2") + provider = HMACTokenProvider() + token = provider.issue("cap.x", "user-1") + assert token.key_id == "k2" + provider.verify(token, expected_principal_id="user-1", expected_capability_id="cap.x") + + +def test_env_secrets_precedes_legacy_single_secret(monkeypatch) -> None: # type: ignore[no-untyped-def] + monkeypatch.setenv("WEAVER_KERNEL_SECRETS", '{"k1": "s1"}') + monkeypatch.setenv("WEAVER_KERNEL_SECRET", "legacy") + provider = HMACTokenProvider() + assert provider.issue("cap.x", "u1").key_id == "k1" + + +def test_env_secrets_malformed_json_fails_closed(monkeypatch) -> None: # type: ignore[no-untyped-def] + monkeypatch.setenv("WEAVER_KERNEL_SECRETS", "{not json") + provider = HMACTokenProvider() + with pytest.raises(AgentKernelError, match="not valid JSON"): + provider.issue("cap.x", "u1") + + +# ── Typed from_dict errors (#200) ────────────────────────────────────────────── + + +def _valid_token_dict() -> dict: # type: ignore[type-arg] + provider = HMACTokenProvider(secret="s1") + return provider.issue("cap.x", "user-1").to_dict() + + +def test_from_dict_valid_roundtrip_unchanged() -> None: + provider = HMACTokenProvider(secret="s1") + token = provider.issue("cap.x", "user-1", constraints={"max_rows": 5}) + restored = CapabilityToken.from_dict(token.to_dict()) + assert restored == token + provider.verify(restored, expected_principal_id="user-1", expected_capability_id="cap.x") + + +@pytest.mark.parametrize("field", ["token_id", "capability_id", "principal_id"]) +def test_from_dict_missing_required_field_raises_token_invalid(field: str) -> None: + data = _valid_token_dict() + del data[field] + with pytest.raises(TokenInvalid, match=f"missing field '{field}'"): + CapabilityToken.from_dict(data) + + +@pytest.mark.parametrize("field", ["issued_at", "expires_at"]) +def test_from_dict_missing_timestamp_raises_token_invalid(field: str) -> None: + data = _valid_token_dict() + del data[field] + with pytest.raises(TokenInvalid, match=f"missing field '{field}'"): + CapabilityToken.from_dict(data) + + +@pytest.mark.parametrize("field", ["issued_at", "expires_at"]) +def test_from_dict_bad_timestamp_raises_token_invalid(field: str) -> None: + data = _valid_token_dict() + data[field] = "not-a-timestamp" + with pytest.raises(TokenInvalid, match=f"invalid timestamp in field '{field}'"): + CapabilityToken.from_dict(data) + + +def test_from_dict_wrong_type_field_raises_token_invalid() -> None: + data = _valid_token_dict() + data["token_id"] = 123 + with pytest.raises(TokenInvalid, match="must be a string"): + CapabilityToken.from_dict(data) + + +def test_from_dict_non_object_constraints_raises_token_invalid() -> None: + 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_tolerates_unknown_extra_keys() -> None: + data = _valid_token_dict() + data["future_field"] = "ignored" + restored = CapabilityToken.from_dict(data) + assert restored.token_id == data["token_id"] + + +# ── Additional fail-closed coverage (#185 / #200) ────────────────────────────── + + +def test_keyring_non_string_values_rejected() -> None: + with pytest.raises(AgentKernelError, match="string key ids to string secrets"): + HMACTokenProvider(secrets={"k1": 123}) # type: ignore[dict-item] + + +def test_env_secrets_non_object_json_rejected(monkeypatch) -> None: # type: ignore[no-untyped-def] + monkeypatch.setenv("WEAVER_KERNEL_SECRETS", '"just-a-string"') + provider = HMACTokenProvider() + with pytest.raises(AgentKernelError, match="JSON object"): + provider.issue("cap.x", "u1") + + +def test_env_active_key_absent_from_map_rejected(monkeypatch) -> None: # type: ignore[no-untyped-def] + monkeypatch.setenv("WEAVER_KERNEL_SECRETS", '{"k1": "s1", "k2": "s2"}') + monkeypatch.setenv("WEAVER_KERNEL_ACTIVE_KEY", "k9") + provider = HMACTokenProvider() + with pytest.raises(AgentKernelError, match="not present"): + provider.issue("cap.x", "u1") + + +@pytest.mark.parametrize("field", ["audit_id", "signature", "key_id"]) +def test_from_dict_non_string_optional_field_rejected(field: str) -> None: + data = _valid_token_dict() + data[field] = 123 + with pytest.raises(TokenInvalid, match="must be a string"): + CapabilityToken.from_dict(data) + + +def test_from_dict_non_string_timestamp_rejected() -> None: + data = _valid_token_dict() + data["issued_at"] = 123 + with pytest.raises(TokenInvalid, match="must be an ISO-8601 string"): + CapabilityToken.from_dict(data) + + +def test_from_dict_naive_timestamp_coerced_to_utc() -> None: + """A naive timestamp is treated as UTC so verify() never hits a naive/aware TypeError.""" + data = _valid_token_dict() + data["issued_at"] = "2026-01-01T00:00:00" # no timezone + data["expires_at"] = "2099-01-01T00:00:00" + token = CapabilityToken.from_dict(data) + assert token.issued_at.tzinfo is datetime.timezone.utc + assert token.expires_at.tzinfo is datetime.timezone.utc