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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Grant-constraint enforcement on handle expansion (#76). `Handle` now carries
the `principal_id` and `constraints` from the original grant, persisted at
handle creation time by `HandleStore.store`. `HandleStore.expand` rechecks
these against the requested expand query:
- A request `limit` larger than the grant's `max_rows` is rejected with
`HandleConstraintViolation` (`reason_code = handle_constraint_violation`).
- A request `fields` entry outside `allowed_fields` is rejected; an
unscoped expand applies `allowed_fields` as the default projection.
- A request filter that disagrees with the grant's `scope` is rejected;
the scope filter is otherwise AND-merged so the caller cannot bypass it.
- A `principal_id` parameter that does not match the handle's stored
principal raises `HandleConstraintViolation`
(`reason_code = handle_principal_mismatch`).
- `SensitivityTag.MEMORY` and memory-action policy rules in
`DefaultPolicyEngine` (#75). Project-scoped memory reads are allowed by
default; sensitive-scoped reads require the `memory_reader_sensitive` role
(or `admin`); writes always require `memory_writer` (or `admin`). The
`explain()` path lists the same conditions with stable `reason_code`s.
- New stable `DenialReason` codes: `HANDLE_CONSTRAINT_VIOLATION`,
`HANDLE_PRINCIPAL_MISMATCH`, `MEMORY_WRITE_REQUIRES_WRITER`,
`MEMORY_SENSITIVE_READ_DENIED`.
- `HandleConstraintViolation` error class (subclass of `AgentKernelError`,
exported from `agent_kernel`) — carries an optional `reason_code` matching
the `DenialReason` vocabulary so handle-side and grant-side denials share
one set of stable codes.
- `Kernel.expand` accepts an optional `principal: Principal` argument that
is forwarded to `HandleStore.expand` for principal-mismatch checks.
- Memory-action input redaction (#75): `ActionTrace.args` for any capability
whose ID starts with `memory.` has payload-like keys (`payload`, `content`,
`value`, `memory`, `text`, `body`) replaced with `"[REDACTED]"`. Keys are
preserved so audit can confirm the action took place without exposing the
durable content the agent wrote or read.
- New `tests/test_firewall_boundary.py` (#74) — focused regression suite that
pushes synthetic secret/PII values through the raw → `Frame` boundary
end-to-end and asserts those values never appear in summary/table/raw
frames, are stripped by `allowed_fields`, never reach `ActionTrace.args`
for memory capabilities, and stay quarantined when raw mode is downgraded
for non-admin principals.

### Security
- Closes #76: handle expansion can no longer return data outside the original
grant's `max_rows` / `allowed_fields` / `scope`, and handle IDs are no
longer bearer credentials that work across principals.
- Closes #75: memory reads and writes are governed actions with stable
denial codes and trace-side redaction of durable payloads.
- Closes #74: redaction boundary is pinned by negative assertions against
fake-secret strings, catching future regressions that drop a redaction
step or route raw data through a new path.

## [0.7.0] - 2026-05-20

### Added
Expand Down
9 changes: 7 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ Both built-in engines satisfy `ExplainingPolicyEngine`:
3. **DESTRUCTIVE** — requires role `admin` + `justification ≥ 15 chars`
4. **PII/PCI** — requires `tenant` attribute; enforces `allowed_fields` unless `pii_reader`
5. **SECRETS** — requires role `admin|secrets_reader` + `justification ≥ 15 chars`
6. **max_rows** — 50 (user), 500 (service)
7. **Rate limiting** — sliding-window per `(principal_id, capability_id)` (60 READ / 10 WRITE / 2 DESTRUCTIVE per 60s; service role gets 10×)
6. **MEMORY** — `memory.read` with `scope.memory_scope == "sensitive"` requires role `memory_reader_sensitive|admin`; `memory.write` / DESTRUCTIVE memory requires role `memory_writer|admin`. Project-scoped memory reads are allowed by default. The kernel also redacts `payload`/`content`/`value`/`memory`/`text`/`body` keys from `ActionTrace.args` for any capability whose ID starts with `memory.`
7. **max_rows** — 50 (user), 500 (service)
8. **Rate limiting** — sliding-window per `(principal_id, capability_id)` (60 READ / 10 WRITE / 2 DESTRUCTIVE per 60s; service role gets 10×)
- **`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.

#### Intent and scope on requests
Expand Down Expand Up @@ -96,6 +97,10 @@ Every `PolicyDecision`, `DenialExplanation`, `FailedCondition`, and `PolicyDenie
| `explicit_deny_rule` | DSL: a `deny` rule matched fully |
| `intent_not_allowed` | DSL: `match.intent` rejected the request's intent |
| `scope_not_allowed` | DSL: `match.scope` rejected the request's scope |
| `handle_constraint_violation` | `HandleStore.expand` request exceeded grant's `max_rows`, `allowed_fields`, or `scope` (#76) |
| `handle_principal_mismatch` | Handle expansion attempted by a different principal than the one the original grant was issued to (#76) |
| `memory_write_requires_writer` | `SensitivityTag.MEMORY` WRITE/DESTRUCTIVE without `memory_writer` or `admin` role (#75) |
| `memory_sensitive_read_denied` | `SensitivityTag.MEMORY` read with `scope.memory_scope == "sensitive"` without `memory_reader_sensitive` or `admin` role (#75) |

Allow-side codes (`AllowReason.*`): `default_policy_allow`, `rule_allow`, `default_fallthrough_allow`, `token_verified`.

Expand Down
53 changes: 53 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
| PII / PCI leakage | Redaction + `allowed_fields` enforcement in the firewall |
| Privilege escalation via WRITE/DESTRUCTIVE | Policy engine enforces role requirements |
| Audit evasion | Every `invoke()` creates an immutable `ActionTrace` |
| Handle scope escape (expand exceeds grant) | Handles persist grant constraints; `HandleStore.expand` rechecks `max_rows`, `allowed_fields`, `scope`, and principal binding (#76) |
| Memory exfiltration via tool output | `SensitivityTag.MEMORY` capabilities gate sensitive reads and durable writes; `ActionTrace.args` redacts payload-like fields for `memory.*` capabilities (#75) |
| Raw memory payload reaching audit log | Kernel strips `payload`/`content`/`value`/`memory`/`text`/`body` from `ActionTrace.args` for `memory.*` capabilities |

## Token scopes

Expand All @@ -27,6 +30,56 @@ Any change to these fields invalidates the HMAC signature.

Consider an agent that obtains a token for `billing.list_invoices` then passes it to a different agent. The second agent cannot use it because `verify()` checks that `token.principal_id == expected_principal_id`.

The same principle extends to handles: every `Handle` carries the `principal_id`
the original grant was issued to. When `handle.principal_id` is non-empty,
`HandleStore.expand` rejects expansion unless the caller supplies a matching
`principal_id`. **An omitted or empty `principal_id` is treated as a
mismatch** (`HandleConstraintViolation`, `reason_code = HANDLE_PRINCIPAL_MISMATCH`),
so a handle ID alone is not a bearer credential — proof of the original
principal is always required. `Kernel.expand(..., principal=Principal(...))`
forwards the principal automatically.

## Handle expansion boundary

Calling `kernel.expand(handle, query=...)` does not re-run the policy engine —
the original grant already authorised the dataset, and handles are short-lived.
But the grant's _constraints_ must still apply, otherwise an over-broad
`expand` query would silently return data the original grant never covered.

`HandleStore.expand` rechecks the constraints the kernel persists on the handle
at creation time (`token.constraints`):

| Constraint | Enforced behavior on expand |
|------------|-----------------------------|
| `max_rows` | A request `limit` larger than the cap raises `HandleConstraintViolation`. An unspecified or larger implicit limit is silently clamped. |
| `allowed_fields` | A request `fields` entry that is not in `allowed_fields` raises `HandleConstraintViolation`. An unscoped expand applies `allowed_fields` as the default projection, so disallowed fields never leak. |
| `scope` (e.g. `{"region": "eu"}`) | The scope filter is AND-merged into the request filter. A request filter that disagrees on a scoped dimension raises `HandleConstraintViolation`. |
| `principal_id` | A mismatched `principal_id` parameter raises `HandleConstraintViolation` (`HANDLE_PRINCIPAL_MISMATCH`). |

Errors carry stable `reason_code` values (`handle_constraint_violation`,
`handle_principal_mismatch`) — assert on those, not on the message text.

## Memory actions

Capabilities tagged `SensitivityTag.MEMORY` represent durable agent memory
(project notes, session handoff, learned context). Reads of project-scoped
memory are allowed by default; reads of sensitive-scoped memory require an
explicit role. Writes always require the `memory_writer` role (or `admin`)
because they persist into future sessions.

| Action | Required role | Denial reason code |
|--------|---------------|--------------------|
| `memory.read` with `scope["memory_scope"] == "project"` | none | — |
| `memory.read` with `scope["memory_scope"] == "sensitive"` | `memory_reader_sensitive` or `admin` | `memory_sensitive_read_denied` |
| `memory.write` (any scope) | `memory_writer` or `admin` | `memory_write_requires_writer` |
| `memory.forget` (DESTRUCTIVE) | `admin` (then `memory_writer` or `admin`) | `missing_role`, then `memory_write_requires_writer` |

To prevent durable memory content from leaking into the audit log, the kernel
strips payload-like fields (`payload`, `content`, `value`, `memory`, `text`,
`body`) from `ActionTrace.args` for any capability whose ID begins with
`memory.`. Non-sensitive metadata keys (`key`, `id`, `scope`, ...) are
preserved so audit can still confirm an action took place.

## Security disclaimers

> **v0.1 is not production-hardened for real authentication.**
Expand Down
1 change: 1 addition & 0 deletions examples/basic_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ async def main() -> None:
expanded = kernel.expand(
frame.handle,
query={"offset": 0, "limit": 3, "fields": ["id", "title"]},
principal=reader,
)
print(" First 3 rows (id + title only):")
for row in expanded.table_preview:
Expand Down
2 changes: 2 additions & 0 deletions examples/billing_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ async def main() -> None:
expanded = kernel.expand(
frame.handle,
query={"offset": 0, "limit": 3, "fields": ["id", "amount", "status"]},
principal=analyst,
)
for row in expanded.table_preview:
print(f" {row}")
Expand All @@ -121,6 +122,7 @@ async def main() -> None:
overdue = kernel.expand(
frame.handle,
query={"filter": {"status": "overdue"}, "limit": 3, "fields": ["id", "amount"]},
principal=analyst,
)
print(f" Overdue rows returned: {len(overdue.table_preview)}")
for row in overdue.table_preview:
Expand Down
1 change: 1 addition & 0 deletions examples/http_driver_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ async def main() -> None:
expanded = kernel.expand(
frame.handle,
query={"limit": 3, "fields": ["id", "name", "price"]},
principal=principal,
)
for row in expanded.table_preview:
print(f" {row}")
Expand Down
2 changes: 2 additions & 0 deletions src/agent_kernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
CapabilityNotFound,
DriverError,
FirewallError,
HandleConstraintViolation,
HandleExpired,
HandleNotFound,
PolicyConfigError,
Expand Down Expand Up @@ -143,6 +144,7 @@
"CapabilityNotFound",
"DriverError",
"FirewallError",
"HandleConstraintViolation",
"HandleExpired",
"HandleNotFound",
"PolicyConfigError",
Expand Down
8 changes: 8 additions & 0 deletions src/agent_kernel/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@ class SensitivityTag(str, Enum):

SECRETS = "SECRETS"
"""Credentials, API keys, tokens."""

MEMORY = "MEMORY"
"""Durable agent memory (project notes, session handoff, learned context).

Reading durable memory may expose sensitive past context; writing creates
durable assumptions that persist into future sessions. Policy treats
writes as higher risk than reads. See ``DefaultPolicyEngine.evaluate``.
"""
19 changes: 19 additions & 0 deletions src/agent_kernel/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,22 @@ class HandleNotFound(AgentKernelError):

class HandleExpired(AgentKernelError):
"""Raised when a handle's TTL has elapsed."""


class HandleConstraintViolation(AgentKernelError):
"""Raised when a handle expansion request violates the grant's constraints.

Handles persist the constraints attached to the original
:class:`~agent_kernel.models.PolicyDecision` (e.g. ``max_rows``,
``allowed_fields``). :meth:`HandleStore.expand` rechecks the requested
query against those constraints; expansions that would exceed the row
cap, request disallowed fields, or violate scope raise this error so
callers can branch on ``reason_code`` rather than parse the message.

Carries the same ``reason_code`` shape as :class:`PolicyDenied` so
metrics and UI mapping use one denial vocabulary across the kernel.
"""

def __init__(self, message: str, *, reason_code: str | None = None) -> None:
super().__init__(message)
self.reason_code: str | None = reason_code
Loading
Loading