feat(security): handle grant constraints, memory policy, redaction boundary tests#79
Open
dgenio wants to merge 2 commits into
Open
feat(security): handle grant constraints, memory policy, redaction boundary tests#79dgenio wants to merge 2 commits into
dgenio wants to merge 2 commits into
Conversation
…edaction boundary tests Implements the recommended security-boundary group (#74, #75, #76): #76 — HandleStore.expand now enforces the grant constraints persisted on the Handle at creation time. Requests that exceed the grant's max_rows, ask for fields outside allowed_fields, or break the scope filter raise HandleConstraintViolation with a stable reason_code. Handles are also principal-scoped — expand by another principal raises with HANDLE_PRINCIPAL_MISMATCH. Handle IDs are no longer bearer credentials. #75 — SensitivityTag.MEMORY plus matching policy rules in DefaultPolicyEngine. Sensitive-scoped memory reads require an explicit role, durable writes always require memory_writer (or admin). The kernel also redacts payload-like keys from ActionTrace.args for any capability whose ID starts with "memory." so durable content never enters the audit log. #74 — New tests/test_firewall_boundary.py pins the raw -> Frame / Handle / ActionTrace seam with synthetic-secret negative assertions across every response mode, plus the memory-trace redaction path. Catches future refactors that drop a redaction step or route raw data through a new path. 477 tests pass (was 450); coverage 96%. https://claude.ai/code/session_01E9jQWivDjJuYMXEFEq9Pq7
There was a problem hiding this comment.
Pull request overview
This PR tightens the kernel’s security boundary at the “grant → handle → expand” seam, introduces explicit policy gating for durable memory actions, and adds regression tests that assert secrets/PII never cross the RawResult→Frame/trace boundary.
Changes:
- Persist grant constraints + principal binding on
Handle, and re-check them duringHandleStore.expand(stable denial codes viaHandleConstraintViolation). - Add
SensitivityTag.MEMORYpolicy rules toDefaultPolicyEngineand redact payload-like args inActionTraceformemory.*capabilities. - Add focused “boundary” tests to prevent regressions in firewall redaction, handle-only/raw behavior, and trace arg redaction.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/agent_kernel/handles.py |
Persists constraints/principal on handles and re-enforces them on expand. |
src/agent_kernel/kernel.py |
Persists principal/constraints into handles; redacts memory args in traces; forwards principal into expand. |
src/agent_kernel/policy.py |
Adds MEMORY policy branch + explain() support with stable reason codes. |
src/agent_kernel/policy_reasons.py |
Introduces new stable denial reason codes for handle+memory paths. |
src/agent_kernel/models.py |
Extends Handle metadata with principal_id and persisted constraints. |
src/agent_kernel/errors.py |
Adds HandleConstraintViolation error with optional reason_code. |
src/agent_kernel/enums.py |
Adds SensitivityTag.MEMORY. |
src/agent_kernel/__init__.py |
Exports HandleConstraintViolation. |
tests/test_handles.py |
Adds tests for grant-constraint enforcement during handle expansion. |
tests/test_policy.py |
Adds tests for memory read/write policy allow/deny behavior and explain() output. |
tests/test_firewall_boundary.py |
New regression suite asserting secrets/PII don’t leak via frames/raw/handles/traces. |
docs/security.md |
Documents handle expansion constraints + memory policies and trace redaction. |
docs/architecture.md |
Updates DefaultPolicyEngine rule ordering + reason-code table. |
CHANGELOG.md |
Documents new security features and tests under Unreleased. |
Comment on lines
+177
to
+184
| # ── Principal binding ───────────────────────────────────────────────── | ||
| if handle.principal_id and principal_id and handle.principal_id != principal_id: | ||
| raise HandleConstraintViolation( | ||
| f"Handle '{handle.handle_id}' was granted to principal " | ||
| f"'{handle.principal_id}' and cannot be expanded by " | ||
| f"'{principal_id}'.", | ||
| reason_code=DenialReason.HANDLE_PRINCIPAL_MISMATCH, | ||
| ) |
Comment on lines
+202
to
223
| if granted_scope: | ||
| filter_spec_in: dict[str, Any] = query.get("filter", {}) or {} | ||
| for sk, sv in granted_scope.items(): | ||
| if sk in filter_spec_in and filter_spec_in[sk] != sv: | ||
| raise HandleConstraintViolation( | ||
| f"Handle '{handle.handle_id}' is scoped to " | ||
| f"{sk}={sv!r}; request filter " | ||
| f"{sk}={filter_spec_in[sk]!r} is outside that scope.", | ||
| reason_code=DenialReason.HANDLE_CONSTRAINT_VIOLATION, | ||
| ) | ||
|
|
||
| data = self.get(handle.handle_id) | ||
| rows: list[Any] = data if isinstance(data, list) else [data] | ||
|
|
||
| # ── Filtering ────────────────────────────────────────────────────────── | ||
| filter_spec: dict[str, Any] = query.get("filter", {}) | ||
| if filter_spec and isinstance(filter_spec, dict): | ||
| # Grant scope is AND-merged into the request filter so the caller | ||
| # cannot bypass it by omitting the scope key. | ||
| filter_spec: dict[str, Any] = dict(query.get("filter", {}) or {}) | ||
| for sk, sv in granted_scope.items(): | ||
| filter_spec.setdefault(sk, sv) | ||
| if filter_spec: | ||
| rows = [ |
Comment on lines
229
to
+236
| # ── Pagination ──────────────────────────────────────────────────────── | ||
| offset = int(query.get("offset", 0)) | ||
| limit = int(query.get("limit", len(rows))) | ||
| requested_limit = query.get("limit") | ||
| limit = len(rows) if requested_limit is None else int(requested_limit) | ||
|
|
||
| if isinstance(granted_max_rows, int) and granted_max_rows >= 0: | ||
| if requested_limit is not None and int(requested_limit) > granted_max_rows: | ||
| raise HandleConstraintViolation( |
Comment on lines
+34
to
+37
| the original grant was issued to, and `HandleStore.expand(..., principal_id=...)` | ||
| rejects expansion attempts from any other principal with | ||
| `HandleConstraintViolation` (`reason_code = HANDLE_PRINCIPAL_MISMATCH`). A | ||
| handle ID alone is not a bearer credential. |
| store.expand(handle, query={}, principal_id="p-attacker") | ||
| assert exc.value.reason_code == DenialReason.HANDLE_PRINCIPAL_MISMATCH | ||
|
|
||
|
|
…put errors Addresses Copilot review on #79: - HandleStore.expand: an omitted/empty principal_id now counts as a mismatch against a principal-bound handle. Previously the check only ran when both sides were non-empty, so a caller could expand by passing principal_id="" (or by letting Kernel.expand default to principal=None). Docs already claimed handles are not bearer credentials — implementation now matches. - HandleStore.expand: validate query inputs up front and raise HandleConstraintViolation (reason_code=INVALID_CONSTRAINT) on a non-dict filter, non-list fields, or non-integer offset/limit. Public APIs no longer leak bare TypeError/ValueError. - New regression tests: - principal-bound handle expanded without principal_id is denied (covers both omitted and explicit "" cases) - non-principal-bound handles still expand without a principal - invalid filter / fields / offset / limit each surface stable codes - Updated existing callers (3 tests, 3 examples) to pass principal= into kernel.expand for principal-bound handles. - Tightened Kernel.expand docstring and docs/security.md to call out the strict enforcement. 483 tests pass (was 477; +6 for new regression coverage). make ci green: ruff format/check, mypy strict, pytest, examples. https://claude.ai/code/session_01E9jQWivDjJuYMXEFEq9Pq7
dgenio
pushed a commit
that referenced
this pull request
May 20, 2026
Three review-comment fixes; all three were valid.
examples/tutorial.py:173 — the leakage line printed but never failed
the run. Replaced with `assert leaked == []` so a future firewall
regression on `allowed_fields` enforcement breaks the build (matches
the PR description's "fail loudly" claim).
docs/tutorial.md:170 — removed the false claim that handle expansion
applies projection "on top of allowed_fields, never around it".
Empirically verified the gap: `kernel.expand(handle, query={"fields":
["email"]})` returns the email even when `allowed_fields=["id"]`,
because `kernel.invoke` stores `raw_result.data` unredacted and
`HandleStore.expand` has no knowledge of the original grant's
`allowed_fields`. New paragraph describes today's behavior accurately
and points readers at issue #76 / PR #79 (the in-flight grant-constraint
work) instead of overpromising.
CHANGELOG.md:18 — tutorial only covers the three LLM-safe response
modes (summary / table / handle_only); admin-only `raw` mode is
mentioned but not exercised. Reworded to match.
Verification: ruff format/check clean; pytest 450 passed; tutorial.py
exits 0.
https://claude.ai/code/session_01E9jQWivDjJuYMXEFEq9Pq7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the security-boundary group identified during triage: #76
(handle grant-constraint enforcement), #75 (memory action policy), and
#74 (redaction boundary tests). One PR because all three changes touch
the same seam — what data is allowed to flow from driver/store outward, and
how policy + trace + firewall enforce it.
This PR also closed two related issues during triage: #73 (decision
trace format) and #77 (stable denial reason codes) — both implemented
by PR #78 but the auto-close didn't fire. Each has a closing comment
explaining the evidence.
What changed
Handle grant-constraint enforcement (#76)
Handlenow carriesprincipal_idandconstraintsfrom the originalgrant, persisted at creation time by
HandleStore.store.HandleStore.expandrechecks against the requested expand query:max_rows,allowed_fields,scopefilter, and principal binding.Violations raise
HandleConstraintViolation(new exception, exportedfrom
agent_kernel) with stablereason_code.expandappliesallowed_fieldsas the default projectionand AND-merges
scopeinto the filter, so a caller can't bypass eitherconstraint by omitting it.
Kernel.expandgained an optionalprincipal: Principalargument thatis forwarded for the principal-binding check.
Memory action policy (#75)
SensitivityTag.MEMORY.DefaultPolicyEngine.evaluateadds a MEMORY branch after existingsensitivity checks (per the "rule placement matters" trap in
docs/agent-context/invariants.md):memory.readwithscope.memory_scope == "sensitive"requiresmemory_reader_sensitiveoradmin.memory.write/ DESTRUCTIVE memory always requiresmemory_writeror
admin.explain()lists the same conditions with stablereason_codes.ActionTrace.args: for capabilities whose IDstarts with
memory., payload-like keys (payload,content,value,memory,text,body) are replaced with"[REDACTED]". Keys arepreserved so audit can still confirm the action.
Redaction boundary tests (#74)
tests/test_firewall_boundary.pywith 8 negative-assertion tests:allowed_fieldsstrips disallowed columns AND their key names.handle_onlyframe carries only a Handle reference (no rows, no raw).rawis silently downgraded to summary;raw_data stays
None.(Bearer tokens, API keys) are still scrubbed by
redact().ActionTrace.argsfor memory capabilities redacts payload-like keys.memory.*— non-memory capabilities areuntouched.
New stable denial codes (added to
agent_kernel.policy_reasons):HANDLE_CONSTRAINT_VIOLATION(handle_constraint_violation)HANDLE_PRINCIPAL_MISMATCH(handle_principal_mismatch)MEMORY_WRITE_REQUIRES_WRITER(memory_write_requires_writer)MEMORY_SENSITIVE_READ_DENIED(memory_sensitive_read_denied)Docs
docs/security.md: new "Handle expansion boundary" and "Memory actions"sections; expanded threat-model table; expanded confused-deputy section
to cover handles.
docs/architecture.md: MEMORY entry inDefaultPolicyEnginerule list;four new rows in the reason-code table.
CHANGELOG.md:[Unreleased]documents the three changes.Why
From triage: handle expansion was the load-bearing seam where over-broad
data could escape the original grant; memory writes persist into future
sessions so they need an explicit policy layer; and the redaction
boundary lacked focused negative-assertion tests pinning the contract.
The three issues share the same modules (
policy.py,handles.py,kernel.py,models.py, the firewall) so doing them together avoidedthree separate rounds of refactoring the same files. Per Mode B and
explicit owner permission, no backwards-compatibility shims were added
to the
Handledataclass.How verified
ruff format --check src/ tests/ examples/— 49 files already formatted.ruff check src/ tests/ examples/— All checks passed.python -m mypy src/— Success: no issues found in 30 source files.python -m pytest -q --cov=agent_kernel— 477 passed (was 450; +27new tests). Coverage 96%.
examples/basic_cli.py,examples/billing_demo.py,examples/http_driver_demo.py— all run clean.Tradeoffs / risks
Handle(per Mode B): the dataclass gainedtwo new fields with safe defaults (empty
principal_id, emptyconstraints). DirectHandle(...)constructors that relied onpositional-only args would break, but every existing test passes
unchanged because all callers use keyword args.
checks to honour the documented invariant ordering. New
SensitivityTagenum values need a matching policy rule — that rule was added in the
same change.
memory.*by capability-ID prefix, not bysensitivity tag, because the input-side redaction needs to apply
regardless of whether the caller set the sensitivity tag correctly.
Non-memory caps are unchanged.
policy_dsl.py(issue [policy/kernel] Tech debt: decompose policy_dsl.py and broaden dry-run driver test coverage #68 D — fileis now 661 lines, even bigger). Did not add an OTel exporter, marketplace,
or memory backend. Each can land separately.
Scope notes (Mode B)
Three issues, one PR. Adjacent issues left as separate follow-ups: #68
(tech debt + dry-run driver coverage), #38 (OTel), #45 (namespaces),
#46 (tutorial), #47 (streaming), #49/#51/#52 (marketplace), #71 (boundary
docs).
Closes
Closes #74, closes #75, closes #76.
(#73 and #77 already closed manually during triage — implemented by PR
#78 but auto-close didn't fire.)
https://claude.ai/code/session_01E9jQWivDjJuYMXEFEq9Pq7
Generated by Claude Code