weaver-kernel 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/AGENTS.md +1 -1
  2. weaver_kernel-0.7.0/CHANGELOG.md +244 -0
  3. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/PKG-INFO +10 -1
  4. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/docs/agent-context/invariants.md +13 -0
  5. weaver_kernel-0.7.0/docs/architecture.md +153 -0
  6. weaver_kernel-0.7.0/docs/capabilities.md +169 -0
  7. weaver_kernel-0.7.0/docs/context_firewall.md +120 -0
  8. weaver_kernel-0.7.0/docs/integrations.md +329 -0
  9. weaver_kernel-0.7.0/examples/policies/default.toml +68 -0
  10. weaver_kernel-0.7.0/examples/policies/default.yaml +64 -0
  11. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/pyproject.toml +29 -2
  12. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/__init__.py +51 -6
  13. weaver_kernel-0.7.0/src/agent_kernel/adapters/__init__.py +35 -0
  14. weaver_kernel-0.7.0/src/agent_kernel/adapters/_base.py +459 -0
  15. weaver_kernel-0.7.0/src/agent_kernel/adapters/anthropic.py +273 -0
  16. weaver_kernel-0.7.0/src/agent_kernel/adapters/openai.py +358 -0
  17. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/mcp.py +4 -2
  18. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/errors.py +62 -1
  19. weaver_kernel-0.7.0/src/agent_kernel/firewall/__init__.py +18 -0
  20. weaver_kernel-0.7.0/src/agent_kernel/firewall/budget_manager.py +275 -0
  21. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/budgets.py +5 -3
  22. weaver_kernel-0.7.0/src/agent_kernel/firewall/token_counting.py +41 -0
  23. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/kernel.py +250 -19
  24. weaver_kernel-0.7.0/src/agent_kernel/models.py +475 -0
  25. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/policy.py +329 -22
  26. weaver_kernel-0.7.0/src/agent_kernel/policy_dsl.py +661 -0
  27. weaver_kernel-0.7.0/src/agent_kernel/policy_reasons.py +92 -0
  28. weaver_kernel-0.7.0/tests/test_adapters.py +1130 -0
  29. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_firewall.py +262 -1
  30. weaver_kernel-0.7.0/tests/test_kernel.py +799 -0
  31. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_models.py +89 -0
  32. weaver_kernel-0.7.0/tests/test_policy.py +1726 -0
  33. weaver_kernel-0.5.0/CHANGELOG.md +0 -81
  34. weaver_kernel-0.5.0/docs/architecture.md +0 -70
  35. weaver_kernel-0.5.0/docs/capabilities.md +0 -49
  36. weaver_kernel-0.5.0/docs/context_firewall.md +0 -64
  37. weaver_kernel-0.5.0/docs/integrations.md +0 -130
  38. weaver_kernel-0.5.0/src/agent_kernel/firewall/__init__.py +0 -8
  39. weaver_kernel-0.5.0/src/agent_kernel/models.py +0 -230
  40. weaver_kernel-0.5.0/tests/test_kernel.py +0 -217
  41. weaver_kernel-0.5.0/tests/test_policy.py +0 -470
  42. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/.claude/CLAUDE.md +0 -0
  43. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/.github/copilot-instructions.md +0 -0
  44. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/.github/workflows/ci.yml +0 -0
  45. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/.github/workflows/publish.yml +0 -0
  46. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/.gitignore +0 -0
  47. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/CONTRIBUTING.md +0 -0
  48. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/LICENSE +0 -0
  49. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/Makefile +0 -0
  50. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/README.md +0 -0
  51. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/RELEASE.md +0 -0
  52. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/docs/agent-context/architecture.md +0 -0
  53. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/docs/agent-context/lessons-learned.md +0 -0
  54. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/docs/agent-context/review-checklist.md +0 -0
  55. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/docs/agent-context/workflows.md +0 -0
  56. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/docs/security.md +0 -0
  57. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/examples/basic_cli.py +0 -0
  58. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/examples/billing_demo.py +0 -0
  59. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/examples/http_driver_demo.py +0 -0
  60. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/__init__.py +0 -0
  61. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/base.py +0 -0
  62. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/http.py +0 -0
  63. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/mcp_support.py +0 -0
  64. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/drivers/memory.py +0 -0
  65. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/enums.py +0 -0
  66. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/redaction.py +0 -0
  67. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/summarize.py +0 -0
  68. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/firewall/transform.py +0 -0
  69. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/handles.py +0 -0
  70. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/py.typed +0 -0
  71. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/registry.py +0 -0
  72. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/router.py +0 -0
  73. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/tokens.py +0 -0
  74. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/src/agent_kernel/trace.py +0 -0
  75. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/conftest.py +0 -0
  76. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_drivers.py +0 -0
  77. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_handles.py +0 -0
  78. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_logging.py +0 -0
  79. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_mcp_driver.py +0 -0
  80. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_redaction.py +0 -0
  81. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_registry.py +0 -0
  82. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_router.py +0 -0
  83. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_tokens.py +0 -0
  84. {weaver_kernel-0.5.0 → weaver_kernel-0.7.0}/tests/test_trace.py +0 -0
@@ -52,7 +52,7 @@ Use these terms consistently. Never substitute synonyms:
52
52
  - Error messages are part of the contract — tests must assert both exception type and message.
53
53
  - Keep modules ≤ 300 lines. Split if needed.
54
54
  - No randomness in matching, routing, or summarization. Deterministic outputs always.
55
- - No new dependencies without justification. The dep list is intentionally minimal (`httpx` only).
55
+ - No new dependencies without justification. The dep list is intentionally minimal (`httpx`, `pydantic`).
56
56
 
57
57
  ## Security rules
58
58
 
@@ -0,0 +1,244 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.7.0] - 2026-05-20
11
+
12
+ ### Added
13
+ - Structured intent and scope metadata on `CapabilityRequest`: new optional
14
+ `intent: str | None` and `scope: dict[str, Any]` fields let policy engines
15
+ authorize based on machine-readable intent and scope alongside the existing
16
+ free-text `goal`. `DeclarativePolicyEngine` rules can match on these via new
17
+ `intent: [...]` and `scope: {key: value}` clauses in YAML/TOML policy files.
18
+ Intent-aware allow rules fail closed for legacy callers that don't set an
19
+ intent. (#72)
20
+ - Structured policy decision trace (`PolicyDecisionTrace` + `PolicyTraceStep`):
21
+ both built-in policy engines now attach a step-by-step trace to every
22
+ `PolicyDecision` (allow and deny paths). Each step records the rule
23
+ considered, the outcome (`matched`/`skipped`/`denied`/`allowed`/
24
+ `constraint_applied`), a human-readable detail, and — for terminal
25
+ steps — the stable reason code. Traces echo `intent` and `scope_keys`
26
+ (scope dimension names only — values redacted) from the request and contain
27
+ no raw argument values. `DryRunResult.policy_decision`
28
+ also carries a synthesized single-step trace. (#73)
29
+ - Stable machine-readable denial reason codes: new `DenialReason` and
30
+ `AllowReason` enums in `agent_kernel.policy_reasons` (also exported as
31
+ `from agent_kernel import DenialReason, AllowReason`). Every built-in
32
+ denial path on `DefaultPolicyEngine` and `DeclarativePolicyEngine` populates
33
+ `PolicyDecision.reason_code`, `DenialExplanation.reason_code`,
34
+ `FailedCondition.reason_code`, and `PolicyDenied.reason_code`. Tests should
35
+ assert on these codes instead of matching the human-readable `reason` /
36
+ `narrative` strings, which remain part of the API but may evolve for
37
+ clarity. Codes: `missing_role`, `missing_tenant_attribute`,
38
+ `missing_attribute`, `insufficient_justification`, `invalid_constraint`,
39
+ `rate_limited`, `no_matching_rule`, `explicit_deny_rule`,
40
+ `intent_not_allowed`, `scope_not_allowed`; allow-side: `default_policy_allow`,
41
+ `rule_allow`, `default_fallthrough_allow`. (#77)
42
+ - New public exports: `AllowReason`, `DenialReason`, `PolicyDecisionTrace`,
43
+ `PolicyTraceStep`.
44
+
45
+ ### Changed
46
+ - `PolicyDecision` gained optional `reason_code: str | None` and
47
+ `trace: PolicyDecisionTrace | None` fields (both default `None` so
48
+ third-party engines that don't populate them keep working).
49
+ - `DenialExplanation` and `FailedCondition` gained optional `reason_code`
50
+ fields populated by both built-in engines on every denial path.
51
+ - `PolicyDenied(reason_code=...)` keyword argument: the exception now carries
52
+ a `reason_code` attribute so callers can branch on a stable code without
53
+ matching the human-readable message.
54
+
55
+ ## [0.6.0] - 2026-05-19
56
+
57
+ ### Added
58
+ - Cross-invocation context budget manager (`BudgetManager`) tracks cumulative token usage across
59
+ multiple `Kernel.invoke()` calls within a session. When attached to a `Kernel` via the new
60
+ `budget_manager` keyword argument, the kernel reserves a budget slice before each invocation
61
+ and reconciles actual frame-payload usage afterwards. As the remaining budget shrinks the
62
+ requested `response_mode` is auto-escalated to a more aggressive tier (> 50% remaining keeps
63
+ the caller's mode; 20–50% downgrades `raw` to `table`; 5–20% floors at `summary`; < 5% forces
64
+ `handle_only`). `Kernel.invoke(..., dry_run=True)` now also reports `budget_remaining` and the
65
+ escalated `response_mode` when a manager is configured. The `BudgetManager` is optional and
66
+ off by default — existing kernels are unchanged. (#44)
67
+ - `TokenCounter` protocol and `default_token_counter` (character-based `len(json.dumps(...))//4`
68
+ approximation) provide pluggable token counting without runtime dependencies. A new optional
69
+ `[tiktoken]` extra is reserved for callers that want to plug in `tiktoken`-based counting.
70
+ - `BudgetExhausted(AgentKernelError)` raised by `BudgetManager.allocate()` (and by
71
+ `Kernel.invoke()` before driver execution) when the cumulative session budget is fully spent.
72
+ - `BudgetConfigError(AgentKernelError)` raised by `BudgetManager` for invalid configuration or
73
+ validation failures (non-positive budgets, negative allocate/record/release amounts), replacing
74
+ bare `ValueError` so callers can catch budget mistakes via the `AgentKernelError` hierarchy
75
+ per `AGENTS.md` ("never raise bare ValueError to callers").
76
+ - New public exports: `BudgetManager`, `BudgetExhausted`, `BudgetConfigError`, `TokenCounter`,
77
+ `default_token_counter`, and `Kernel.budget` accessor property.
78
+ - LLM tool-format adapters and middleware (`agent_kernel.adapters`): `OpenAIMiddleware` (OpenAI
79
+ Responses API + Chat Completions, auto-detected on input) and `AnthropicMiddleware` (Anthropic
80
+ Messages with `cache_control` support). Both translate `Capability` objects to vendor tool
81
+ schemas, route tool calls through the full kernel pipeline (grant → invoke → firewall → trace),
82
+ and surface kernel errors (`PolicyDenied`, `CapabilityNotFound`, `DriverError`) as tool-result
83
+ errors so the LLM can react. Pre/post hooks (`intercept_tool_call`, `intercept_tool_result`,
84
+ sync or async) support logging, metrics, approval gates, and per-call justification injection.
85
+ Zero runtime dependency on the `openai` / `anthropic` SDK packages. (#55, #50, #40)
86
+ - New `Capability` fields for LLM adapters: `parameters_model: type[pydantic.BaseModel] | None`
87
+ (input schema source + validation), `parameters_schema: dict | None` (raw JSON Schema escape
88
+ hatch), and `tool_hints: ToolHints | None` (vendor hints — Anthropic `cache_control`, OpenAI
89
+ `strict` mode). All default to ``None``; existing capabilities and tests are unaffected.
90
+ - New `ToolHints` dataclass and `OpenAIMiddleware` / `AnthropicMiddleware` top-level exports.
91
+ - New `AdapterParseError(AgentKernelError)` exception raised by adapter parse / validation
92
+ helpers (`tool_call_to_request`, `tool_use_to_request`, `make_namespace_safe_name`) instead
93
+ of bare `ValueError`. Satisfies `AGENTS.md`'s "no bare ValueError to callers" rule and
94
+ gives consumers a stable adapter-specific exception type. Also catches capability IDs that
95
+ contain the reserved OpenAI namespace separator `__` (which would otherwise produce
96
+ colliding tool names).
97
+ - `Kernel.list_capabilities()` convenience accessor returning every registered capability in
98
+ registration order. Used by the new adapters but generally useful for tooling that needs to
99
+ enumerate the registry without keyword search.
100
+ - Declarative policy engine (`DeclarativePolicyEngine`) that loads rules from YAML or TOML files.
101
+ Rules are evaluated top-down with first-match-wins semantics; supports `safety_class`, `sensitivity`,
102
+ `roles`, `attributes`, and `min_justification` match conditions. (#42)
103
+ - Policy denial explanation: `ExplainingPolicyEngine` protocol plus `DefaultPolicyEngine.explain()` and
104
+ `DeclarativePolicyEngine.explain()` implementations return a structured `DenialExplanation` with a
105
+ `FailedCondition` list for every failing check (no short-circuit), a `remediation` list, and a
106
+ human-readable `narrative`. (#48)
107
+ - Dry-run invocation mode: `kernel.invoke(..., dry_run=True)` verifies the token and resolves the
108
+ execution plan without calling the driver. Returns `DryRunResult` with the resolved `driver_id`,
109
+ `operation`, `response_mode`, and an `estimated_cost` tier (`low`/`medium`/`high`). (#43)
110
+ - `Kernel.explain_denial()` convenience method that calls the policy engine's `explain()` for a given
111
+ `CapabilityRequest` and `Principal` without requiring a token. Raises `AgentKernelError` when the
112
+ configured engine does not implement `explain()`.
113
+ - New public types exported from `agent_kernel`: `DeclarativePolicyEngine`, `ExplainingPolicyEngine`,
114
+ `PolicyEngine`, `PolicyMatch`, `PolicyRule`, `DenialExplanation`, `FailedCondition`, `DryRunResult`,
115
+ `PolicyConfigError`.
116
+ - `policy` optional extra (`pip install weaver-kernel[policy]`) pulls in `pyyaml` and `tomli` (Python 3.10).
117
+ - Example policy files in `examples/policies/` (YAML and TOML formats).
118
+
119
+ ### Changed
120
+ - Runtime dependencies now include `pydantic>=2` in addition to `httpx`. Pydantic is used by the new
121
+ `agent_kernel.adapters` package for JSON-Schema generation and argument validation when a
122
+ `Capability` declares a `parameters_model`. Existing kernel behavior is unchanged; pydantic is not
123
+ imported at module load by anything outside the adapters.
124
+ - `PolicyEngine` protocol no longer requires `explain()`. Engines that need to support
125
+ `Kernel.explain_denial()` should implement the new `ExplainingPolicyEngine` protocol. Built-in
126
+ engines satisfy both. This avoids a breaking typing change for downstream implementers.
127
+ - `DeclarativePolicyEngine` now defers `yaml` and `tomllib`/`tomli` imports into the corresponding
128
+ loaders, so `import agent_kernel` works without the `policy` extra installed. Calling
129
+ `from_yaml`/`from_toml` without the parser surfaces a `PolicyConfigError` with an install hint.
130
+ - `Kernel.invoke(dry_run=True)` resolves `operation` the same way drivers do
131
+ (`args.get("operation", capability_id)`) so `DryRunResult.operation` matches what a driver would
132
+ actually receive — instead of `capability.impl.operation`, which can diverge.
133
+ - `Kernel.invoke(dry_run=True)` mirrors the Firewall's admin-only gate for `raw` mode: non-admin
134
+ principals see their requested `raw` mode downgraded to `summary` in `DryRunResult`, matching
135
+ what they would actually get at real-invoke time. Prevents probing for raw availability.
136
+
137
+ ### Documentation
138
+ - `docs/architecture.md` now describes `PolicyEngine` / `ExplainingPolicyEngine` protocols,
139
+ `DefaultPolicyEngine` and `DeclarativePolicyEngine` (with policy-DSL semantics), and dry-run
140
+ mode (admin gate, operation resolution rule). Closes the canonical "Components & API
141
+ reference" gap flagged in audit.
142
+ - `docs/capabilities.md` adds a "Dry-run mode" section (semantics, the three parity rules,
143
+ no-side-effects guarantee), a "Declarative policies" section (loaders, match conditions,
144
+ optional-extra behaviour), and a "Denial explanations" section. Closes the affected-files
145
+ gap from issue #43.
146
+
147
+ ### Fixed
148
+ - `DeclarativePolicyEngine._parse_rule()` now validates the types of `roles`, `attributes`,
149
+ `min_justification`, and `constraints` in policy files and raises `PolicyConfigError` with a
150
+ precise message instead of silently producing misbehaving rules or raising at evaluation time.
151
+ - `DeclarativePolicyEngine.explain()` now correctly reports explicit deny rules that fully match
152
+ (previously fell through to the misleading `no_matching_rule` fallback and dropped the rule's
153
+ reason). Partial-match deny rules are now skipped so the explanation focuses on actionable allow
154
+ rules instead of suggesting changes that would only trigger the deny.
155
+ - Example policy files (`examples/policies/default.{yaml,toml}`) now use the correct `default` key
156
+ (was `default_action`, which the parser silently ignored), express PII-with-tenant as an allow
157
+ rule paired with default-deny (the previous deny rule was inverted under first-match-wins), and
158
+ order the `allow-secrets-service` rule before the deny rule (the deny was previously unreachable).
159
+ - `Kernel.explain_denial()` docstring no longer contradicts itself ("never raises" vs.
160
+ `CapabilityNotFound`).
161
+ - `DryRunResult.budget_remaining` docstring no longer references the unimplemented `BudgetManager`;
162
+ the field is documented as reserved for a future cross-invocation budget mechanism.
163
+ - `drivers/mcp.py` adds an explicit `_McpError: type[BaseException] | None` annotation so mypy
164
+ `--strict` remains happy across the try/except import branches.
165
+
166
+ ### Tests
167
+ - `tests/test_policy.py` adds `test_declarative_replicates_default_policy_decisions` — a
168
+ comparative test asserting that `DeclarativePolicyEngine` and `DefaultPolicyEngine` produce
169
+ the same allow/deny outcomes across a curated scenario matrix (READ × non-sensitive / PII /
170
+ PCI / SECRETS, WRITE/DESTRUCTIVE with and without required roles and justification). Closes
171
+ issue #42's "comparative test" acceptance criterion.
172
+
173
+ ## [0.5.0] - 2026-04-12
174
+
175
+ ### Added
176
+ - Built-in `MCPDriver` with stdio and Streamable HTTP transports, tool auto-discovery, normalized MCP result handling, and optional dependency guardrails.
177
+ - Declared weaver-spec v0.1.0 compatibility in README: invariants I-01 (firewall), I-02 (authorization + audit), and I-06 (scoped tokens) are satisfied.
178
+ - Added placeholder `conformance_stub` CI job that will activate once the weaver-spec conformance suite ships (dgenio/weaver-spec#4).
179
+
180
+ ## [0.4.0] - 2026-03-14
181
+
182
+ ### Added
183
+ - Sliding-window rate limiting in `DefaultPolicyEngine` per `(principal_id, capability_id)` pair (#39).
184
+ Default limits by safety class: 60 READ / 10 WRITE / 2 DESTRUCTIVE per 60s window.
185
+ Service-role principals get 10× limits. Configurable via constructor.
186
+ - GitHub Release step in publish workflow — creates a release with auto-generated notes and artifacts before publishing to PyPI.
187
+
188
+ ### Fixed
189
+ - `HTTPDriver`: DELETE requests now forward args as query params instead of silently dropping them.
190
+
191
+ ### Removed
192
+ - Dead `_truncate_str` helper in `firewall/transform.py` (defined but never called).
193
+
194
+ ## [0.3.0] - 2026-03-09
195
+
196
+ ### Added
197
+ - Structured logging at kernel decision points (invoke, grant, deny, revoke).
198
+ - Agent-facing documentation system: `docs/agent-context/` (architecture, workflows, invariants, lessons-learned, review-checklist).
199
+ - `.github/copilot-instructions.md` — review-critical projections for GitHub Copilot.
200
+ - `.claude/CLAUDE.md` — Claude-specific operating instructions.
201
+ - PyPI publish workflow (`.github/workflows/publish.yml`) with Trusted Publisher (OIDC) (#37).
202
+ - `RELEASE.md` documenting the full release process.
203
+ - `[project.urls]` in `pyproject.toml` (Homepage, Repository, Documentation, Changelog).
204
+ - Optional dependency groups: `mcp` and `otel` in `pyproject.toml`.
205
+
206
+ ### Changed
207
+ - Rewrote `AGENTS.md` with full domain vocabulary, security rules, code conventions, documentation map, and weaver-spec references.
208
+ - Renamed PyPI package from `agent-kernel` to `weaver-kernel` to align with Weaver ecosystem.
209
+ - Added `workflow_call` trigger to CI workflow so publish workflow can reuse it as a gate.
210
+
211
+ ### Refactored
212
+ - Extracted `_log_verify_failure` helper in `tokens.py`.
213
+ - Consolidated invoke logging with shared base dict in `kernel.py`.
214
+ - Extracted `_deny` static method in policy engine.
215
+
216
+ ### Fixed
217
+ - Pinned GitHub Actions to commit SHAs in publish workflow.
218
+ - Added `contents:read` permission to publish job.
219
+ - Clarified PyPI vs import name in README Quickstart.
220
+
221
+ ## [0.2.0] - 2026-03-06
222
+
223
+ ### Added
224
+ - Token revocation support: `revoke_token()` and `revoke_all()` on `Kernel` (#33, #57).
225
+ - `SECRETS` sensitivity tag enforcement in policy engine and redaction (#56).
226
+
227
+ ### Fixed
228
+ - Policy engine now strips whitespace from justification before length check.
229
+ - Policy engine reports both raw and stripped length in justification errors.
230
+ - Policy engine checks role before justification in all safety/sensitivity blocks.
231
+ - Redaction preserves field-name context in API key and connection string patterns.
232
+ - `revoke_all()` drops `_principal_tokens` entry after revoking.
233
+
234
+ ## [0.1.0] - 2024-01-01
235
+
236
+ ### Added
237
+ - Initial scaffold: `CapabilityRegistry`, `PolicyEngine`, `HMACTokenProvider`, `Kernel`.
238
+ - `InMemoryDriver` and `HTTPDriver` (httpx-based).
239
+ - Context `Firewall` with `Budgets`, redaction, and summarization.
240
+ - `HandleStore` with TTL, pagination, field selection, and basic filtering.
241
+ - `TraceStore` and `explain()` for full audit trail.
242
+ - Examples: `basic_cli.py`, `billing_demo.py`, `http_driver_demo.py`.
243
+ - Documentation: architecture, security model, integrations, capabilities, context firewall.
244
+ - CI pipeline for Python 3.10, 3.11, 3.12 with ruff + mypy + pytest.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weaver-kernel
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Capability-based security kernel for AI agents operating in large tool ecosystems
5
5
  Project-URL: Homepage, https://github.com/dgenio/agent-kernel
6
6
  Project-URL: Repository, https://github.com/dgenio/agent-kernel
@@ -221,6 +221,7 @@ Classifier: Topic :: Security
221
221
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
222
222
  Requires-Python: >=3.10
223
223
  Requires-Dist: httpx>=0.27
224
+ Requires-Dist: pydantic>=2
224
225
  Provides-Extra: dev
225
226
  Requires-Dist: httpx>=0.27; extra == 'dev'
226
227
  Requires-Dist: mcp>=1.6; extra == 'dev'
@@ -228,11 +229,19 @@ Requires-Dist: mypy>=1.10; extra == 'dev'
228
229
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
229
230
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
230
231
  Requires-Dist: pytest>=8.0; extra == 'dev'
232
+ Requires-Dist: pyyaml>=6.0; extra == 'dev'
231
233
  Requires-Dist: ruff>=0.4; extra == 'dev'
234
+ Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'dev'
235
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
232
236
  Provides-Extra: mcp
233
237
  Requires-Dist: mcp>=1.6; extra == 'mcp'
234
238
  Provides-Extra: otel
235
239
  Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
240
+ Provides-Extra: policy
241
+ Requires-Dist: pyyaml>=6.0; extra == 'policy'
242
+ Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'policy'
243
+ Provides-Extra: tiktoken
244
+ Requires-Dist: tiktoken>=0.6; extra == 'tiktoken'
236
245
  Description-Content-Type: text/markdown
237
246
 
238
247
  # agent-kernel
@@ -64,6 +64,19 @@ tag is **silently ignored** — capabilities tagged with it pass policy without
64
64
 
65
65
  **Rule:** When adding a `SensitivityTag`, always add a matching policy rule and test.
66
66
 
67
+ ### Dry-run response-mode parity
68
+ `Kernel.invoke(dry_run=True)` reports the response mode the caller would actually
69
+ get at real-invoke time. The Firewall downgrades `raw` to `summary` for non-admin
70
+ principals (`firewall/transform.py:108`), so dry-run must mirror that downgrade —
71
+ otherwise a non-admin caller can probe/assume raw-mode availability they will never
72
+ actually receive. The same applies to `operation`: dry-run resolves it the same way
73
+ drivers do (`args.get("operation", capability_id)`), so what the caller sees in
74
+ `DryRunResult` matches what a driver would receive.
75
+
76
+ **Rule:** Any code path that reports a response mode or driver operation back to the
77
+ caller must apply the same admin gate / resolution rule the real-invoke path uses,
78
+ including dry-run, mock, and test paths.
79
+
67
80
  ## Safe vs. unsafe changes
68
81
 
69
82
  | Safe | Unsafe |
@@ -0,0 +1,153 @@
1
+ # Architecture
2
+
3
+ ## Overview
4
+
5
+ `agent-kernel` is a capability-based security kernel that sits **above** raw tool execution (MCP, HTTP APIs, internal services) and **below** the LLM context window.
6
+
7
+ ```mermaid
8
+ graph TD
9
+ LLM["LLM / Agent"] -->|goal text| K["Kernel"]
10
+ K -->|search| REG["CapabilityRegistry"]
11
+ REG -->|CapabilityRequest| K
12
+ K -->|evaluate| POL["PolicyEngine"]
13
+ POL -->|PolicyDecision| K
14
+ K -->|issue| TOK["TokenProvider (HMAC)"]
15
+ TOK -->|CapabilityToken| K
16
+ K -->|route| ROU["Router"]
17
+ ROU -->|RoutePlan| K
18
+ K -->|execute| DRV["Driver (Memory / HTTP / MCP)"]
19
+ DRV -->|RawResult| K
20
+ K -->|transform| FW["Firewall"]
21
+ FW -->|Frame| K
22
+ K -->|store| HS["HandleStore"]
23
+ K -->|record| TS["TraceStore"]
24
+ K -->|Frame| LLM
25
+ ```
26
+
27
+ ## Components
28
+
29
+ ### Kernel
30
+ The central orchestrator. Wires all components together and exposes:
31
+ - `request_capabilities(goal)` — discover relevant capabilities
32
+ - `grant_capability(request, principal, justification)` — policy check + token issuance
33
+ - `invoke(token, principal, args, response_mode, dry_run=False)` — execute + firewall + trace, or short-circuit before driver dispatch when `dry_run=True`
34
+ - `expand(handle, query)` — paginate/filter stored results
35
+ - `explain(action_id)` — retrieve audit trace
36
+ - `explain_denial(request, principal, justification)` — return a structured `DenialExplanation` instead of raising `PolicyDenied`
37
+
38
+ ### CapabilityRegistry
39
+ A flat dict of `Capability` objects indexed by `capability_id`. Provides keyword-based search (no LLM, no vector DB — purely token overlap scoring).
40
+
41
+ ### PolicyEngine
42
+ Two protocols and two built-in engines:
43
+
44
+ - **`PolicyEngine`** (protocol) — single required method: `evaluate(request, capability, principal, justification) -> PolicyDecision`.
45
+ - **`ExplainingPolicyEngine`** (protocol, extends `PolicyEngine`) — adds `explain(...) -> DenialExplanation`. Only engines that implement this protocol can be used with `Kernel.explain_denial`; otherwise that call raises `AgentKernelError` with a clear message. Splitting the contract keeps existing downstream `PolicyEngine` implementers backward-compatible.
46
+
47
+ Both built-in engines satisfy `ExplainingPolicyEngine`:
48
+
49
+ - **`DefaultPolicyEngine`** — hardcoded role-based rules:
50
+ 1. **READ** — always allowed
51
+ 2. **WRITE** — requires `justification ≥ 15 chars` + role `writer|admin`
52
+ 3. **DESTRUCTIVE** — requires role `admin` + `justification ≥ 15 chars`
53
+ 4. **PII/PCI** — requires `tenant` attribute; enforces `allowed_fields` unless `pii_reader`
54
+ 5. **SECRETS** — requires role `admin|secrets_reader` + `justification ≥ 15 chars`
55
+ 6. **max_rows** — 50 (user), 500 (service)
56
+ 7. **Rate limiting** — sliding-window per `(principal_id, capability_id)` (60 READ / 10 WRITE / 2 DESTRUCTIVE per 60s; service role gets 10×)
57
+ - **`DeclarativePolicyEngine`** — loads rules from a YAML or TOML file (or a plain dict). Supports `safety_class`, `sensitivity`, `roles`, `attributes`, `min_justification`, `intent`, and `scope` match conditions; `allow`/`deny` actions; per-rule `constraints` merged into the resulting `PolicyDecision`; configurable `default` action. Rules are evaluated top-down with first-match-wins. `pyyaml` and `tomli` are optional dependencies — `import agent_kernel` works without them; calling `from_yaml`/`from_toml` without the parser raises `PolicyConfigError` with an install hint.
58
+
59
+ #### Intent and scope on requests
60
+
61
+ `CapabilityRequest` carries optional structured metadata alongside its free-text `goal`:
62
+
63
+ - `intent: str | None` — a machine-readable label (e.g. `"customer_support_lookup"`).
64
+ - `scope: dict[str, Any]` — a small structured map (e.g. `{"region": "eu-west", "customer_id": "C-42"}`).
65
+
66
+ `DeclarativePolicyEngine` rules can match on these via top-level keys in `match`:
67
+
68
+ ```yaml
69
+ - name: support_eu_lookup
70
+ match:
71
+ safety_class: [READ]
72
+ intent: [customer_support_lookup]
73
+ scope: { region: "eu-west" }
74
+ action: allow
75
+ ```
76
+
77
+ Intent-aware rules fail closed: a request with `intent=None` never matches a rule that requires a specific intent. `scope: { key: "*" }` means "the key must be present with any value".
78
+
79
+ #### Denial explanations
80
+
81
+ `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.
82
+
83
+ #### Reason codes
84
+
85
+ Every `PolicyDecision`, `DenialExplanation`, `FailedCondition`, and `PolicyDenied` from the built-in engines carries a stable `reason_code`. Assert on these codes — not on the human-readable `reason` / `narrative` strings:
86
+
87
+ | Code (`DenialReason.*`) | When |
88
+ |---|---|
89
+ | `missing_role` | Principal lacks a required role |
90
+ | `missing_tenant_attribute` | PII/PCI capability needs `tenant` attribute |
91
+ | `missing_attribute` | Declarative rule's required attribute absent or mismatched |
92
+ | `insufficient_justification` | Justification shorter than the minimum |
93
+ | `invalid_constraint` | Constraint value (e.g. `max_rows`) not parseable |
94
+ | `rate_limited` | Sliding-window rate limit exceeded |
95
+ | `no_matching_rule` | DSL: no rule matched + default `deny` |
96
+ | `explicit_deny_rule` | DSL: a `deny` rule matched fully |
97
+ | `intent_not_allowed` | DSL: `match.intent` rejected the request's intent |
98
+ | `scope_not_allowed` | DSL: `match.scope` rejected the request's scope |
99
+
100
+ Allow-side codes (`AllowReason.*`): `default_policy_allow`, `rule_allow`, `default_fallthrough_allow`, `token_verified`.
101
+
102
+ #### Decision trace
103
+
104
+ Every `PolicyDecision` from a built-in engine carries a `PolicyDecisionTrace` describing how the decision was reached: the engine name, the capability and principal IDs, the request's `intent` (echoed) and `scope_keys` (scope dimension names only — values are redacted), and an ordered list of `PolicyTraceStep` entries. Each step records the rule name, the outcome (`matched`/`skipped`/`denied`/`allowed`/`constraint_applied`), a human-readable detail, and — for terminal steps — the same stable `reason_code` carried on the decision. Traces are safe to log and serialize: they contain rule names, condition names, and codes only — never raw argument values.
105
+
106
+ #### Dry-run mode
107
+
108
+ `Kernel.invoke(dry_run=True)` verifies the token and resolves the route plan but **never calls the driver**. It returns a `DryRunResult` with the resolved `driver_id`, the same `operation` a driver would receive (`args.get("operation", capability_id)`), the request constraints, the effective `response_mode` (Firewall's admin-only gate is mirrored: non-admin `raw` is downgraded to `summary`), and a coarse `estimated_cost` tier based on `SafetyClass`. Token verification still raises `TokenExpired` / `TokenInvalid` / `TokenScopeError` in dry-run, so the mode is safe as a policy/route sanity check. See [`docs/capabilities.md`](capabilities.md#dry-run-mode) for usage and [`docs/agent-context/invariants.md`](agent-context/invariants.md) for the parity rule with the real-invoke path.
109
+
110
+ ### TokenProvider (HMAC)
111
+ Issues HMAC-SHA256 signed tokens. Each token is bound to `principal_id + capability_id + constraints`. Verification checks: expiry → signature → principal → capability.
112
+
113
+ ### Router
114
+ `StaticRouter` maps `capability_id → [driver_id, ...]`. First driver that succeeds wins; others are tried as fallbacks.
115
+
116
+ ### Drivers
117
+ - **InMemoryDriver** — Python callables, used for tests and demos
118
+ - **HTTPDriver** — `httpx`-based async HTTP client
119
+ - (Future) **MCPDriver** — adapter for Model Context Protocol tool servers
120
+
121
+ ### Firewall
122
+ Transforms `RawResult → Frame`. Never exposes raw output to the LLM.
123
+ - Four response modes: `summary`, `table`, `handle_only`, `raw`
124
+ - Enforces `Budgets` (max_rows, max_fields, max_chars, max_depth)
125
+ - Redacts sensitive fields and inline PII patterns
126
+ - Deterministic summarisation (no LLM)
127
+
128
+ ### HandleStore
129
+ Stores full results by opaque handle ID with TTL. `expand()` supports pagination, field selection, and basic equality filtering.
130
+
131
+ ### TraceStore
132
+ Records every `ActionTrace`. `explain(action_id)` returns the full audit record.
133
+
134
+ ### Adapters (`agent_kernel.adapters`)
135
+ Vendor-specific tool-format adapters that translate between `Capability` objects
136
+ and the tool shapes used by LLM provider APIs:
137
+
138
+ - **`OpenAIMiddleware`** — emits OpenAI tool definitions (Responses API or Chat
139
+ Completions shape), parses `response.output` / `message.tool_calls`, and
140
+ returns `function_call_output` / tool-result messages. Dotted capability IDs
141
+ map to `namespace__function` (OpenAI tool names cannot contain `.`).
142
+ - **`AnthropicMiddleware`** — emits Anthropic tool definitions with optional
143
+ `cache_control` blocks, parses `tool_use` content blocks, and returns
144
+ `tool_result` content blocks. Dotted capability IDs are preserved as-is.
145
+
146
+ Both classes share `BaseToolMiddleware`, which owns hook registration
147
+ (`intercept_tool_call`, `intercept_tool_result`), pre/post dispatch (sync or
148
+ async), and conversion of kernel exceptions (`PolicyDenied`,
149
+ `CapabilityNotFound`, `DriverError`) into tool-result errors the LLM can react
150
+ to. Input arguments are validated against `Capability.parameters_model`
151
+ (pydantic) when present. **Zero runtime dependency** on the `openai` /
152
+ `anthropic` SDK packages. See [`docs/integrations.md`](integrations.md) for
153
+ usage examples.
@@ -0,0 +1,169 @@
1
+ # Designing Capabilities
2
+
3
+ ## Naming conventions
4
+
5
+ - Use `domain.verb_noun` format: `billing.list_invoices`, `users.get_profile`.
6
+ - Be specific: prefer `billing.cancel_invoice` over `billing.update`.
7
+ - Avoid generic names like `billing.execute` or `api.call`.
8
+
9
+ ## Granularity
10
+
11
+ Each capability should map to a single, auditable action with clear side-effects.
12
+
13
+ **Good:**
14
+ - `billing.list_invoices` (READ, no side-effects)
15
+ - `billing.send_reminder` (WRITE, sends an email)
16
+ - `billing.void_invoice` (DESTRUCTIVE, irreversible)
17
+
18
+ **Avoid:**
19
+ - `billing.do_stuff` (too broad)
20
+ - `billing.list_or_update_invoices` (mixed safety classes)
21
+
22
+ ## Safety classes
23
+
24
+ | Class | Examples | Policy |
25
+ |-------|---------|--------|
26
+ | READ | list, get, search, summarize | Always allowed |
27
+ | WRITE | create, update, send, approve | Justification + writer role |
28
+ | DESTRUCTIVE | delete, void, purge, terminate | Admin role only |
29
+
30
+ ## Sensitivity tags
31
+
32
+ Use `SensitivityTag.PII` when results may contain: name, email, phone, SSN, address.
33
+ Use `SensitivityTag.PCI` when results may contain: card numbers, CVV, bank details.
34
+ Use `SensitivityTag.SECRETS` when results may contain: API keys, passwords, tokens.
35
+
36
+ Always pair sensitivity tags with `allowed_fields` to restrict which fields are returned
37
+ to non-privileged callers.
38
+
39
+ ## Tags
40
+
41
+ Add descriptive tags to improve keyword matching:
42
+
43
+ ```python
44
+ Capability(
45
+ capability_id="billing.list_invoices",
46
+ tags=["billing", "invoices", "list", "finance", "accounts receivable"],
47
+ ...
48
+ )
49
+ ```
50
+
51
+ ## Dry-run mode
52
+
53
+ `Kernel.invoke(..., dry_run=True)` verifies the token and resolves the route
54
+ plan but **never calls the driver**. Use it to validate that a principal can
55
+ invoke a capability, inspect what a driver *would* receive, or run policy
56
+ checks in CI without live tool backends.
57
+
58
+ ```python
59
+ result = await kernel.invoke(
60
+ token,
61
+ principal=principal,
62
+ args={"operation": "billing.list_invoices", "max_rows": 5},
63
+ response_mode="summary",
64
+ dry_run=True,
65
+ )
66
+ # result: DryRunResult(
67
+ # capability_id="billing.list_invoices",
68
+ # principal_id="user-001",
69
+ # policy_decision=PolicyDecision(allowed=True, ...),
70
+ # driver_id="billing",
71
+ # operation="billing.list_invoices",
72
+ # resolved_args={"operation": "billing.list_invoices", "max_rows": 5},
73
+ # response_mode="summary",
74
+ # budget_remaining=None,
75
+ # estimated_cost="low",
76
+ # )
77
+ ```
78
+
79
+ Three rules govern dry-run behaviour — keep them in sync with the real-invoke
80
+ path if you change either:
81
+
82
+ 1. **Token verification still runs.** Expired, revoked, or scope-mismatched
83
+ tokens raise `TokenExpired` / `TokenRevoked` / `TokenInvalid` /
84
+ `TokenScopeError` exactly as they would at real-invoke. Policy is *not*
85
+ re-evaluated at invoke time — the granting policy decision is encoded in
86
+ the token at `grant_capability`.
87
+ 2. **Operation resolution mirrors drivers.** `DryRunResult.operation` is
88
+ computed the same way every driver computes it:
89
+ `str(args.get("operation", capability_id))`. Always use `args["operation"]`
90
+ when you need a fixed operation; otherwise the dry-run operation is the
91
+ capability ID, matching what the driver would see.
92
+ 3. **Raw-mode admin gate mirrors the Firewall.** Non-admin principals never
93
+ get `response_mode="raw"` at real-invoke (the Firewall downgrades it to
94
+ `"summary"` — see `firewall/transform.py`). Dry-run downgrades the same
95
+ way, so non-admin callers cannot probe for raw-mode availability via
96
+ `DryRunResult`.
97
+
98
+ The driver's `execute()` is never called in dry-run, so the mode is free of
99
+ side effects regardless of driver type (`InMemoryDriver`, `HTTPDriver`,
100
+ `MCPDriver`). `DryRunResult.budget_remaining` is currently always `None`; the
101
+ field is reserved for a future cross-invocation budget mechanism.
102
+
103
+ ## Declarative policies
104
+
105
+ `DeclarativePolicyEngine` is an alternative to `DefaultPolicyEngine` that
106
+ loads rules from a YAML or TOML file (or a plain dict). Rules are evaluated
107
+ top-down, first-match-wins; if no rule matches, the policy's `default` action
108
+ applies (`"deny"` unless overridden).
109
+
110
+ ```python
111
+ from pathlib import Path
112
+ from agent_kernel import DeclarativePolicyEngine, Kernel
113
+
114
+ # YAML or TOML — both formats are interchangeable.
115
+ policy = DeclarativePolicyEngine.from_yaml(Path("examples/policies/default.yaml"))
116
+
117
+ # Or build entirely in-memory:
118
+ policy = DeclarativePolicyEngine.from_dict({
119
+ "default": "deny",
120
+ "rules": [
121
+ {"name": "allow-read", "action": "allow",
122
+ "match": {"safety_class": ["READ"], "sensitivity": ["NONE"]}},
123
+ # ...
124
+ ],
125
+ })
126
+
127
+ kernel = Kernel(registry=registry, policy=policy)
128
+ ```
129
+
130
+ A rule's `match` block supports `safety_class`, `sensitivity`, `roles`
131
+ (ANY-of), `attributes` (ALL-of, with `"*"` meaning "attribute must be
132
+ present"), and `min_justification` (minimum stripped length). On `allow`, the
133
+ rule's `constraints` are merged into the resulting `PolicyDecision`. On
134
+ `deny`, `reason` is embedded in the raised `PolicyDenied`.
135
+
136
+ The DSL has no negation/missing-attribute operator today, so a policy that
137
+ should deny "when an attribute is missing" should be expressed as an allow
138
+ rule requiring the attribute paired with `default: deny`. See
139
+ [`examples/policies/default.yaml`](../examples/policies/default.yaml) for a
140
+ worked example.
141
+
142
+ `pyyaml` and `tomli` are **optional** — they live behind the `[policy]`
143
+ extra. `import agent_kernel` always works; calling `from_yaml` / `from_toml`
144
+ without the parser installed raises `PolicyConfigError` with an install hint.
145
+
146
+ ## Denial explanations
147
+
148
+ When a capability call is denied, `Kernel.explain_denial(request, principal,
149
+ justification="")` returns a structured `DenialExplanation` describing
150
+ **every** unmet condition (not just the first one), so the caller can see the
151
+ full remediation path:
152
+
153
+ ```python
154
+ explanation = kernel.explain_denial(
155
+ CapabilityRequest(capability_id="billing.update_invoice", goal="..."),
156
+ principal,
157
+ justification="too short",
158
+ )
159
+ # explanation.denied == True
160
+ # explanation.rule_name == "write-min_justification"
161
+ # explanation.failed_conditions == [FailedCondition(condition="roles", required=[...]), ...]
162
+ # explanation.remediation == ["Add 'writer' or 'admin' role to ...", "Provide ..."]
163
+ # explanation.narrative == "Request for 'billing.update_invoice' by '...' would be denied: ..."
164
+ ```
165
+
166
+ Both built-in engines support `explain()`. If you bring a custom policy
167
+ engine that implements only `PolicyEngine.evaluate`, `explain_denial` raises
168
+ `AgentKernelError` with guidance — implement the `ExplainingPolicyEngine`
169
+ protocol to enable structured explanations.