Skip to content

Commit 0b30506

Browse files
authored
Merge pull request #78 from dgenio/claude/triage-issues-J4v3C
feat(policy): intent/scope, decision trace, and stable reason codes
2 parents 56c07af + 019f984 commit 0b30506

12 files changed

Lines changed: 1256 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Structured intent and scope metadata on `CapabilityRequest`: new optional
12+
`intent: str | None` and `scope: dict[str, Any]` fields let policy engines
13+
authorize based on machine-readable intent and scope alongside the existing
14+
free-text `goal`. `DeclarativePolicyEngine` rules can match on these via new
15+
`intent: [...]` and `scope: {key: value}` clauses in YAML/TOML policy files.
16+
Intent-aware allow rules fail closed for legacy callers that don't set an
17+
intent. (#72)
18+
- Structured policy decision trace (`PolicyDecisionTrace` + `PolicyTraceStep`):
19+
both built-in policy engines now attach a step-by-step trace to every
20+
`PolicyDecision` (allow and deny paths). Each step records the rule
21+
considered, the outcome (`matched`/`skipped`/`denied`/`allowed`/
22+
`constraint_applied`), a human-readable detail, and — for terminal
23+
steps — the stable reason code. Traces echo `intent` and `scope_keys`
24+
(scope dimension names only — values redacted) from the request and contain
25+
no raw argument values. `DryRunResult.policy_decision`
26+
also carries a synthesized single-step trace. (#73)
27+
- Stable machine-readable denial reason codes: new `DenialReason` and
28+
`AllowReason` enums in `agent_kernel.policy_reasons` (also exported as
29+
`from agent_kernel import DenialReason, AllowReason`). Every built-in
30+
denial path on `DefaultPolicyEngine` and `DeclarativePolicyEngine` populates
31+
`PolicyDecision.reason_code`, `DenialExplanation.reason_code`,
32+
`FailedCondition.reason_code`, and `PolicyDenied.reason_code`. Tests should
33+
assert on these codes instead of matching the human-readable `reason` /
34+
`narrative` strings, which remain part of the API but may evolve for
35+
clarity. Codes: `missing_role`, `missing_tenant_attribute`,
36+
`missing_attribute`, `insufficient_justification`, `invalid_constraint`,
37+
`rate_limited`, `no_matching_rule`, `explicit_deny_rule`,
38+
`intent_not_allowed`, `scope_not_allowed`; allow-side: `default_policy_allow`,
39+
`rule_allow`, `default_fallthrough_allow`. (#77)
40+
- New public exports: `AllowReason`, `DenialReason`, `PolicyDecisionTrace`,
41+
`PolicyTraceStep`.
42+
43+
### Changed
44+
- `PolicyDecision` gained optional `reason_code: str | None` and
45+
`trace: PolicyDecisionTrace | None` fields (both default `None` so
46+
third-party engines that don't populate them keep working).
47+
- `DenialExplanation` and `FailedCondition` gained optional `reason_code`
48+
fields populated by both built-in engines on every denial path.
49+
- `PolicyDenied(reason_code=...)` keyword argument: the exception now carries
50+
a `reason_code` attribute so callers can branch on a stable code without
51+
matching the human-readable message.
52+
1053
## [0.6.0] - 2026-05-19
1154

1255
### Added

docs/architecture.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,54 @@ Both built-in engines satisfy `ExplainingPolicyEngine`:
5454
5. **SECRETS** — requires role `admin|secrets_reader` + `justification ≥ 15 chars`
5555
6. **max_rows** — 50 (user), 500 (service)
5656
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`, and `min_justification` 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.
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".
5878

5979
#### Denial explanations
6080

61-
`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`, a `remediation` list, and a human-readable `narrative`. 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.
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.
62105

63106
#### Dry-run mode
64107

src/agent_kernel/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
Policy::
1717
1818
from agent_kernel import DefaultPolicyEngine, DeclarativePolicyEngine
19+
from agent_kernel import PolicyDecisionTrace, PolicyTraceStep
20+
from agent_kernel import DenialReason, AllowReason
1921
2022
Firewall::
2123
@@ -82,6 +84,8 @@
8284
Handle,
8385
ImplementationRef,
8486
PolicyDecision,
87+
PolicyDecisionTrace,
88+
PolicyTraceStep,
8589
Principal,
8690
Provenance,
8791
RawResult,
@@ -91,6 +95,7 @@
9195
)
9296
from .policy import DefaultPolicyEngine, ExplainingPolicyEngine, PolicyEngine
9397
from .policy_dsl import DeclarativePolicyEngine, PolicyMatch, PolicyRule
98+
from .policy_reasons import AllowReason, DenialReason
9499
from .registry import CapabilityRegistry
95100
from .router import StaticRouter
96101
from .tokens import CapabilityToken, HMACTokenProvider
@@ -117,6 +122,8 @@
117122
"Handle",
118123
"ImplementationRef",
119124
"PolicyDecision",
125+
"PolicyDecisionTrace",
126+
"PolicyTraceStep",
120127
"Principal",
121128
"Provenance",
122129
"RawResult",
@@ -145,8 +152,10 @@
145152
"TokenRevoked",
146153
"TokenScopeError",
147154
# policy
155+
"AllowReason",
148156
"DefaultPolicyEngine",
149157
"DeclarativePolicyEngine",
158+
"DenialReason",
150159
"ExplainingPolicyEngine",
151160
"PolicyEngine",
152161
"PolicyMatch",

src/agent_kernel/errors.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,24 @@ class TokenRevoked(AgentKernelError):
2828

2929

3030
class PolicyDenied(AgentKernelError):
31-
"""Raised when the policy engine rejects a capability request."""
31+
"""Raised when the policy engine rejects a capability request.
32+
33+
Carries an optional ``reason_code`` attribute holding a stable
34+
:class:`~agent_kernel.policy_reasons.DenialReason` value so callers can
35+
branch on it without matching the human-readable message:
36+
37+
.. code-block:: python
38+
39+
try:
40+
kernel.grant_capability(request, principal, justification="...")
41+
except PolicyDenied as exc:
42+
if exc.reason_code == DenialReason.MISSING_ROLE:
43+
...
44+
"""
45+
46+
def __init__(self, message: str, *, reason_code: str | None = None) -> None:
47+
super().__init__(message)
48+
self.reason_code: str | None = reason_code
3249

3350

3451
class PolicyConfigError(AgentKernelError):

src/agent_kernel/kernel.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@
2323
Frame,
2424
Handle,
2525
PolicyDecision,
26+
PolicyDecisionTrace,
27+
PolicyTraceStep,
2628
Principal,
2729
ResponseMode,
2830
RoutePlan,
2931
)
3032
from .policy import DefaultPolicyEngine, PolicyEngine
33+
from .policy_reasons import AllowReason
3134
from .registry import CapabilityRegistry
3235
from .router import Router, StaticRouter
3336
from .tokens import CapabilityToken, HMACTokenProvider, TokenProvider
@@ -303,13 +306,32 @@ async def invoke(
303306
SafetyClass.WRITE: "medium",
304307
SafetyClass.DESTRUCTIVE: "high",
305308
}
309+
dry_run_trace = PolicyDecisionTrace(
310+
engine="Kernel.invoke[dry_run]",
311+
capability_id=token.capability_id,
312+
principal_id=principal.principal_id,
313+
intent=None,
314+
scope_keys=[],
315+
steps=[
316+
PolicyTraceStep(
317+
name="token_verified",
318+
outcome="allowed",
319+
detail="Token verified; original policy decision was at grant time.",
320+
reason_code=str(AllowReason.TOKEN_VERIFIED),
321+
)
322+
],
323+
final_outcome="allowed",
324+
final_reason_code=str(AllowReason.TOKEN_VERIFIED),
325+
)
306326
return DryRunResult(
307327
capability_id=token.capability_id,
308328
principal_id=principal.principal_id,
309329
policy_decision=PolicyDecision(
310330
allowed=True,
311331
reason="Token verified. Policy was evaluated at grant time.",
312332
constraints=dict(token.constraints),
333+
reason_code=str(AllowReason.TOKEN_VERIFIED),
334+
trace=dry_run_trace,
313335
),
314336
driver_id=driver_id,
315337
operation=operation,

src/agent_kernel/models.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,22 @@ class CapabilityRequest:
134134
constraints: dict[str, Any] = field(default_factory=dict)
135135
"""Optional execution constraints (e.g. ``{"max_rows": 10}``)."""
136136

137+
intent: str | None = None
138+
"""Structured intent label (e.g. ``"customer_support_lookup"``).
139+
140+
Free-text :attr:`goal` is still required for human-readable audit; ``intent``
141+
is the machine-readable counterpart that declarative policies can match
142+
on directly without parsing the goal. See :class:`PolicyMatch.intent`.
143+
"""
144+
145+
scope: dict[str, Any] = field(default_factory=dict)
146+
"""Structured scope metadata describing what the request narrows to.
147+
148+
Examples: ``{"region": "eu-west"}``, ``{"customer_id": "C-42"}``. Policies
149+
can deny a capability invocation that is technically allowed but unsafe
150+
for a particular scope. See :class:`PolicyMatch.scope`.
151+
"""
152+
137153

138154
@dataclass(slots=True)
139155
class Principal:
@@ -149,6 +165,77 @@ class Principal:
149165
"""Arbitrary attributes, e.g. ``{"tenant": "acme"}``."""
150166

151167

168+
@dataclass(slots=True)
169+
class PolicyTraceStep:
170+
"""A single step recorded while a policy engine evaluated a request.
171+
172+
Steps describe what the engine considered, in order — which rule it
173+
examined, whether it matched, what condition (if any) failed, and what
174+
constraint (if any) was applied. Steps never contain raw argument values
175+
from the caller; they reference fields and IDs only.
176+
"""
177+
178+
name: str
179+
"""Short label for the step (e.g. ``"safety_class:WRITE"`` or rule name)."""
180+
181+
outcome: Literal["matched", "skipped", "denied", "allowed", "constraint_applied"]
182+
"""What happened at this step.
183+
184+
- ``"matched"``: a rule's match clause matched and evaluation continues.
185+
- ``"skipped"``: the step did not apply (e.g. wildcard, wrong safety class).
186+
- ``"denied"``: this step produced the final denial.
187+
- ``"allowed"``: this step produced the final allow.
188+
- ``"constraint_applied"``: the step merged a constraint into the decision.
189+
"""
190+
191+
detail: str = ""
192+
"""Human-readable detail, e.g. ``"role 'writer' required, principal had ['reader']"``."""
193+
194+
reason_code: str | None = None
195+
"""For ``"denied"`` steps, the :class:`~agent_kernel.policy_reasons.DenialReason`.
196+
For ``"allowed"`` steps, the :class:`~agent_kernel.policy_reasons.AllowReason`.
197+
``None`` for ``"matched"``, ``"skipped"``, and ``"constraint_applied"`` steps.
198+
"""
199+
200+
201+
@dataclass(slots=True)
202+
class PolicyDecisionTrace:
203+
"""Structured trace of how a :class:`PolicyDecision` was reached.
204+
205+
The trace lists every step the policy engine took, in order, so callers
206+
can audit which rule matched, which conditions failed, and which
207+
constraints were applied. The trace must not contain raw argument
208+
values — only field names, role names, attribute names, rule names, and
209+
safe IDs — so it is safe to serialize and log.
210+
"""
211+
212+
engine: str
213+
"""Engine identifier (e.g. ``"DefaultPolicyEngine"``)."""
214+
215+
capability_id: str
216+
"""The capability that was being evaluated."""
217+
218+
principal_id: str
219+
"""The principal the decision was made for."""
220+
221+
intent: str | None
222+
"""Echoed :attr:`CapabilityRequest.intent` (may be ``None``)."""
223+
224+
scope_keys: list[str] = field(default_factory=list)
225+
"""Scope dimension names present on the request (values redacted for safety)."""
226+
227+
steps: list[PolicyTraceStep] = field(default_factory=list)
228+
"""Ordered list of evaluation steps."""
229+
230+
final_outcome: Literal["allowed", "denied"] = "denied"
231+
"""The decision the engine reached."""
232+
233+
final_reason_code: str | None = None
234+
"""The :class:`~agent_kernel.policy_reasons.AllowReason` or
235+
:class:`~agent_kernel.policy_reasons.DenialReason` for the final outcome.
236+
"""
237+
238+
152239
@dataclass(slots=True)
153240
class PolicyDecision:
154241
"""Result of a policy engine evaluation."""
@@ -157,11 +244,27 @@ class PolicyDecision:
157244
"""``True`` if the request is permitted."""
158245

159246
reason: str
160-
"""Human-readable explanation."""
247+
"""Human-readable explanation. Wording may evolve; assert on
248+
:attr:`reason_code` for stable behavior."""
161249

162250
constraints: dict[str, Any] = field(default_factory=dict)
163251
"""Any additional constraints imposed by the policy (e.g. ``max_rows``)."""
164252

253+
reason_code: str | None = None
254+
"""Stable machine-readable code (typically a :class:`~agent_kernel.policy_reasons.AllowReason`
255+
or :class:`~agent_kernel.policy_reasons.DenialReason` value).
256+
257+
Use this for assertions, metrics, and UI mapping. ``None`` only when an
258+
out-of-tree policy engine has not populated it.
259+
"""
260+
261+
trace: PolicyDecisionTrace | None = None
262+
"""Structured trace of how this decision was reached.
263+
264+
Populated by both built-in engines on allow and deny paths. ``None`` for
265+
third-party engines that don't produce a trace.
266+
"""
267+
165268

166269
@dataclass(slots=True)
167270
class CapabilityGrant:
@@ -306,6 +409,12 @@ class FailedCondition:
306409
suggestion: str
307410
"""Actionable remediation hint."""
308411

412+
reason_code: str | None = None
413+
"""Stable machine-readable code (a :class:`~agent_kernel.policy_reasons.DenialReason` value).
414+
Use this for assertions instead of matching the human-readable
415+
:attr:`suggestion` string.
416+
"""
417+
309418

310419
@dataclass(slots=True)
311420
class DenialExplanation:
@@ -326,6 +435,12 @@ class DenialExplanation:
326435
narrative: str
327436
"""Human-readable single-sentence summary."""
328437

438+
reason_code: str | None = None
439+
"""Primary :class:`~agent_kernel.policy_reasons.DenialReason` for the denial
440+
(typically the code of the first :class:`FailedCondition`). ``None`` on the
441+
allow path (``denied=False``).
442+
"""
443+
329444

330445
# ── Dry-run ───────────────────────────────────────────────────────────────────
331446

0 commit comments

Comments
 (0)