From c22ea8b7590786267073b5e432d8c0184a80697d Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 22:45:46 +0100 Subject: [PATCH 01/25] Add read-only rate-limit inspection --- src/weaver_kernel/rate_limit.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/weaver_kernel/rate_limit.py b/src/weaver_kernel/rate_limit.py index b74d664..bbef7cc 100644 --- a/src/weaver_kernel/rate_limit.py +++ b/src/weaver_kernel/rate_limit.py @@ -66,6 +66,22 @@ def check(self, key: str, limit: int, window_seconds: float) -> bool: return True return len(entry.timestamps) < limit + def peek(self, key: str, limit: int, window_seconds: float) -> bool: + """Read-only counterpart to :meth:`check`. + + Returns whether the next invocation would be within the limit without + creating a window, pruning expired timestamps, or otherwise mutating + limiter state. Policy explanation uses this path so explaining a + decision can never consume or rewrite rate-limit budget. + """ + now = self._clock() + cutoff = now - window_seconds + entry = self._windows.get(key) + if entry is None: + return True + active = sum(timestamp > cutoff for timestamp in entry.timestamps) + return active < limit + def record(self, key: str) -> None: """Record an invocation for *key*.""" self._windows[key].timestamps.append(self._clock()) From eb640cd09af613872a8ae4f08bf81d9afc329ed6 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 22:46:41 +0100 Subject: [PATCH 02/25] Add evaluate/explain agreement and no-mutation tests --- tests/test_policy_rule_chain.py | 252 ++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 tests/test_policy_rule_chain.py diff --git a/tests/test_policy_rule_chain.py b/tests/test_policy_rule_chain.py new file mode 100644 index 0000000..f3ce7df --- /dev/null +++ b/tests/test_policy_rule_chain.py @@ -0,0 +1,252 @@ +"""Agreement and read-only invariants for DefaultPolicyEngine's shared rule chain.""" + +from __future__ import annotations + +from copy import deepcopy + +import pytest + +from weaver_kernel import ( + Capability, + DefaultPolicyEngine, + PolicyDenied, + Principal, + SafetyClass, + SensitivityTag, +) +from weaver_kernel.models import CapabilityRequest +from weaver_kernel.policy_reasons import DenialReason + + +def _cap( + safety: SafetyClass, + *, + sensitivity: SensitivityTag = SensitivityTag.NONE, + allowed_fields: list[str] | None = None, +) -> Capability: + return Capability( + capability_id="cap.test", + name="test", + description="test capability", + safety_class=safety, + sensitivity=sensitivity, + allowed_fields=allowed_fields or [], + ) + + +def _request(*, max_rows: object | None = None, memory_scope: str | None = None) -> CapabilityRequest: + constraints = {} if max_rows is None else {"max_rows": max_rows} + scope = {} if memory_scope is None else {"memory_scope": memory_scope} + return CapabilityRequest( + capability_id="cap.test", + goal="test", + constraints=constraints, + scope=scope, + ) + + +_CASES = [ + pytest.param( + _request(), + _cap(SafetyClass.READ), + Principal(principal_id="reader"), + "", + False, + None, + id="read-allowed", + ), + pytest.param( + _request(), + _cap(SafetyClass.WRITE), + Principal(principal_id="no-writer", roles=["reader"]), + "long enough justification", + True, + str(DenialReason.MISSING_ROLE), + id="write-role", + ), + pytest.param( + _request(), + _cap(SafetyClass.WRITE), + Principal(principal_id="writer", roles=["writer"]), + "short", + True, + str(DenialReason.INSUFFICIENT_JUSTIFICATION), + id="write-justification", + ), + pytest.param( + _request(), + _cap(SafetyClass.DESTRUCTIVE), + Principal(principal_id="not-admin", roles=["writer"]), + "long enough justification", + True, + str(DenialReason.MISSING_ROLE), + id="destructive-role", + ), + pytest.param( + _request(), + _cap(SafetyClass.READ, sensitivity=SensitivityTag.PII), + Principal(principal_id="pii"), + "", + True, + str(DenialReason.MISSING_TENANT_ATTRIBUTE), + id="pii-tenant", + ), + pytest.param( + _request(), + _cap(SafetyClass.READ, sensitivity=SensitivityTag.SECRETS), + Principal(principal_id="secret-reader", roles=["reader"]), + "long enough justification", + True, + str(DenialReason.MISSING_ROLE), + id="secrets-role", + ), + pytest.param( + _request(), + _cap(SafetyClass.WRITE, sensitivity=SensitivityTag.MEMORY), + Principal(principal_id="memory-writer", roles=["writer"]), + "long enough justification", + True, + str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), + id="memory-write-role", + ), + pytest.param( + _request(memory_scope="sensitive"), + _cap(SafetyClass.READ, sensitivity=SensitivityTag.MEMORY), + Principal(principal_id="memory-reader", roles=["reader"]), + "", + True, + str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), + id="memory-sensitive-read-role", + ), + pytest.param( + _request(max_rows="not-an-int"), + _cap(SafetyClass.READ), + Principal(principal_id="invalid-constraint"), + "", + True, + str(DenialReason.INVALID_CONSTRAINT), + id="invalid-max-rows", + ), + pytest.param( + _request(max_rows=9999), + _cap(SafetyClass.READ, sensitivity=SensitivityTag.PII, allowed_fields=["id"]), + Principal(principal_id="service", roles=["service"], attributes={"tenant": "acme"}), + "", + False, + None, + id="allowed-with-constraints", + ), +] + + +@pytest.mark.parametrize( + ("request", "capability", "principal", "justification", "denied", "reason_code"), + _CASES, +) +def test_explain_prediction_matches_evaluate( + request: CapabilityRequest, + capability: Capability, + principal: Principal, + justification: str, + denied: bool, + reason_code: str | None, +) -> None: + engine = DefaultPolicyEngine() + + explanation = engine.explain( + request, + capability, + principal, + justification=justification, + ) + + try: + decision = engine.evaluate( + request, + capability, + principal, + justification=justification, + ) + except PolicyDenied as exc: + evaluated_denied = True + evaluated_reason = exc.reason_code + else: + evaluated_denied = not decision.allowed + evaluated_reason = decision.reason_code if evaluated_denied else None + + assert explanation.denied is denied + assert evaluated_denied is denied + assert explanation.denied == evaluated_denied + assert explanation.reason_code == reason_code + assert evaluated_reason == reason_code + + +def _limiter_state(engine: DefaultPolicyEngine) -> dict[str, list[float]]: + return { + key: list(entry.timestamps) + for key, entry in engine._limiter._windows.items() # noqa: SLF001 - invariant test + } + + +def test_explain_rate_limit_path_is_strictly_read_only() -> None: + now = [100.0] + engine = DefaultPolicyEngine( + rate_limits={SafetyClass.READ: (1, 60.0)}, + clock=lambda: now[0], + ) + request = _request() + capability = _cap(SafetyClass.READ) + principal = Principal(principal_id="rate-user") + + first = engine.evaluate(request, capability, principal, justification="") + assert first.allowed is True + before = deepcopy(_limiter_state(engine)) + + explanation = engine.explain(request, capability, principal, justification="") + + assert explanation.denied is True + assert explanation.reason_code == str(DenialReason.RATE_LIMITED) + assert _limiter_state(engine) == before + with pytest.raises(PolicyDenied) as excinfo: + engine.evaluate(request, capability, principal, justification="") + assert excinfo.value.reason_code == str(DenialReason.RATE_LIMITED) + + +def test_explain_does_not_prune_expired_rate_entries() -> None: + now = [100.0] + engine = DefaultPolicyEngine( + rate_limits={SafetyClass.READ: (1, 60.0)}, + clock=lambda: now[0], + ) + request = _request() + capability = _cap(SafetyClass.READ) + principal = Principal(principal_id="rate-user") + engine.evaluate(request, capability, principal, justification="") + now[0] = 161.0 + before = deepcopy(_limiter_state(engine)) + + explanation = engine.explain(request, capability, principal, justification="") + + assert explanation.denied is False + assert _limiter_state(engine) == before + assert engine.evaluate(request, capability, principal, justification="").allowed is True + + +def test_explain_collects_all_failures_while_evaluate_short_circuits() -> None: + engine = DefaultPolicyEngine() + request = _request(max_rows="bad") + capability = _cap(SafetyClass.WRITE, sensitivity=SensitivityTag.PII) + principal = Principal(principal_id="many-failures", roles=["reader"]) + + explanation = engine.explain(request, capability, principal, justification="short") + + assert explanation.denied is True + assert [failure.condition for failure in explanation.failed_conditions] == [ + "roles", + "min_justification", + "tenant_attribute", + "max_rows", + ] + with pytest.raises(PolicyDenied) as excinfo: + engine.evaluate(request, capability, principal, justification="short") + assert excinfo.value.reason_code == str(DenialReason.MISSING_ROLE) From 40b921ac9f72a9a7bb0ad6f3eee165b563ac9be8 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 22:49:59 +0100 Subject: [PATCH 03/25] Define shared default policy rule chain --- src/weaver_kernel/default_policy_rules.py | 400 ++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 src/weaver_kernel/default_policy_rules.py diff --git a/src/weaver_kernel/default_policy_rules.py b/src/weaver_kernel/default_policy_rules.py new file mode 100644 index 0000000..e0cbd74 --- /dev/null +++ b/src/weaver_kernel/default_policy_rules.py @@ -0,0 +1,400 @@ +"""Shared rule chain for :class:`~weaver_kernel.policy.DefaultPolicyEngine`. + +This module owns the ordered default-policy conditions. ``evaluate()`` and +``explain()`` deliberately traverse this same chain with different modes: +short-circuit + stateful rate limiting for decisions, collect-all + read-only +rate inspection for explanations. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .enums import SafetyClass, SensitivityTag +from .models import ( + Capability, + CapabilityRequest, + FailedCondition, + PolicyTraceStep, + Principal, +) +from .policy_reasons import DenialReason +from .rate_limit import SERVICE_RATE_MULTIPLIER, RateLimiter + +MIN_JUSTIFICATION = 15 +MAX_ROWS_USER = 50 +MAX_ROWS_SERVICE = 500 + + +@dataclass(slots=True) +class RuleFailure: + """One failed default-policy condition and its decision/explanation views.""" + + detail: str + condition: FailedCondition + reason_code: str + cause: Exception | None = None + + +@dataclass(slots=True) +class RuleChainResult: + """Result of traversing the ordered default-policy rule chain.""" + + constraints: dict[str, Any] + failures: list[RuleFailure] = field(default_factory=list) + trace_steps: list[PolicyTraceStep] = field(default_factory=list) + + +class DefaultPolicyRuleChain: + """Single ordered definition of the built-in default policy rules.""" + + def __init__( + self, + *, + rate_limits: dict[SafetyClass, tuple[int, float]], + limiter: RateLimiter, + ) -> None: + self._rate_limits = rate_limits + self._limiter = limiter + + def run( + self, + request: CapabilityRequest, + capability: Capability, + principal: Principal, + *, + justification: str, + collect_all: bool, + read_only: bool, + ) -> RuleChainResult: + """Traverse the rules once in canonical order. + + Args: + request: Capability request being checked. + capability: Target capability. + principal: Requesting principal. + justification: Caller-supplied justification. + collect_all: Collect every failure instead of stopping at the first. + read_only: Do not mutate transient policy state such as rate windows. + + Returns: + Constraints, failed conditions, and non-terminal trace steps. + """ + roles = set(principal.roles) + constraints: dict[str, Any] = dict(request.constraints) + result = RuleChainResult(constraints=constraints) + pid = principal.principal_id + cid = capability.capability_id + + def add_failure( + *, + detail: str, + condition: FailedCondition, + reason_code: str, + cause: Exception | None = None, + ) -> bool: + result.failures.append( + RuleFailure( + detail=detail, + condition=condition, + reason_code=reason_code, + cause=cause, + ) + ) + return not collect_all + + # ── Safety class checks ────────────────────────────────────────── + if capability.safety_class == SafetyClass.WRITE: + if not (roles & {"writer", "admin"}): + detail = ( + f"WRITE capabilities require the 'writer' or 'admin' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="roles", + required=["writer", "admin"], + actual=sorted(roles), + suggestion=f"Add 'writer' or 'admin' role to principal '{pid}'", + reason_code=str(DenialReason.MISSING_ROLE), + ), + reason_code=str(DenialReason.MISSING_ROLE), + ): + return result + stripped_len = len(justification.strip()) + if stripped_len < MIN_JUSTIFICATION: + detail = ( + f"WRITE capabilities require a justification of at least " + f"{MIN_JUSTIFICATION} characters. " + f"Got {len(justification)} characters " + f"({stripped_len} after trimming whitespace)." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="min_justification", + required=MIN_JUSTIFICATION, + actual=stripped_len, + suggestion=( + f"Provide justification with at least {MIN_JUSTIFICATION} " + f"characters (currently {stripped_len})" + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ): + return result + + elif capability.safety_class == SafetyClass.DESTRUCTIVE: + if "admin" not in roles: + detail = ( + f"DESTRUCTIVE capabilities require the 'admin' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="roles", + required=["admin"], + actual=sorted(roles), + suggestion=f"Add 'admin' role to principal '{pid}'", + reason_code=str(DenialReason.MISSING_ROLE), + ), + reason_code=str(DenialReason.MISSING_ROLE), + ): + return result + stripped_len = len(justification.strip()) + if stripped_len < MIN_JUSTIFICATION: + detail = ( + f"DESTRUCTIVE capabilities require a justification of at least " + f"{MIN_JUSTIFICATION} characters. " + f"Got {len(justification)} characters " + f"({stripped_len} after trimming whitespace)." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="min_justification", + required=MIN_JUSTIFICATION, + actual=stripped_len, + suggestion=( + f"Provide justification with at least {MIN_JUSTIFICATION} " + f"characters (currently {stripped_len})" + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ): + return result + + # ── Sensitivity checks ─────────────────────────────────────────── + if capability.sensitivity in (SensitivityTag.PII, SensitivityTag.PCI): + if "tenant" not in principal.attributes: + detail = ( + f"Capability '{cid}' has " + f"{capability.sensitivity.value} sensitivity and requires " + "the principal to have a 'tenant' attribute." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="tenant_attribute", + required="present", + actual="absent", + suggestion=f"Add 'tenant' attribute to principal '{pid}'", + reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), + ), + reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), + ): + return result + if capability.allowed_fields and "pii_reader" not in roles: + constraints["allowed_fields"] = capability.allowed_fields + result.trace_steps.append( + PolicyTraceStep( + name="sensitivity:allowed_fields", + outcome="constraint_applied", + detail=f"applied allowed_fields={capability.allowed_fields}", + ) + ) + + if capability.sensitivity == SensitivityTag.SECRETS: + if not (roles & {"admin", "secrets_reader"}): + detail = ( + f"SECRETS capabilities require the 'admin' or 'secrets_reader' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="roles", + required=["admin", "secrets_reader"], + actual=sorted(roles), + suggestion=f"Add 'admin' or 'secrets_reader' role to principal '{pid}'", + reason_code=str(DenialReason.MISSING_ROLE), + ), + reason_code=str(DenialReason.MISSING_ROLE), + ): + return result + stripped_len = len(justification.strip()) + if stripped_len < MIN_JUSTIFICATION: + detail = ( + f"SECRETS capabilities require a justification of at least " + f"{MIN_JUSTIFICATION} characters. " + f"Got {len(justification)} characters " + f"({stripped_len} after trimming whitespace)." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="min_justification", + required=MIN_JUSTIFICATION, + actual=stripped_len, + suggestion=( + f"Provide justification with at least {MIN_JUSTIFICATION} " + f"characters (currently {stripped_len})" + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ): + return result + + # ── Memory checks ──────────────────────────────────────────────── + if capability.sensitivity == SensitivityTag.MEMORY: + memory_scope = str(request.scope.get("memory_scope", "")) if request.scope else "" + is_write = capability.safety_class in ( + SafetyClass.WRITE, + SafetyClass.DESTRUCTIVE, + ) + if is_write and not (roles & {"memory_writer", "admin"}): + detail = ( + f"MEMORY write capabilities require the 'memory_writer' or " + f"'admin' role. Principal '{pid}' has roles: {sorted(roles)}." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="roles", + required=["memory_writer", "admin"], + actual=sorted(roles), + suggestion=f"Add 'memory_writer' or 'admin' role to principal '{pid}'", + reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), + ), + reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), + ): + return result + if ( + not is_write + and memory_scope == "sensitive" + and not (roles & {"memory_reader_sensitive", "admin"}) + ): + detail = ( + f"MEMORY read with scope='sensitive' requires the " + f"'memory_reader_sensitive' or 'admin' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="roles", + required=["memory_reader_sensitive", "admin"], + actual=sorted(roles), + suggestion=( + f"Add 'memory_reader_sensitive' or 'admin' role to " + f"principal '{pid}' (or narrow the request scope away " + f"from 'sensitive')" + ), + reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), + ), + reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), + ): + return result + + # ── Row cap ────────────────────────────────────────────────────── + max_rows = MAX_ROWS_SERVICE if "service" in roles else MAX_ROWS_USER + if "max_rows" in constraints: + try: + requested = int(constraints["max_rows"]) + except (TypeError, ValueError) as exc: + detail = ( + f"Invalid 'max_rows' constraint: {constraints['max_rows']!r} " + "is not a valid integer." + ) + if add_failure( + detail=detail, + condition=FailedCondition( + condition="max_rows", + required="integer", + actual=constraints["max_rows"], + suggestion="Provide 'max_rows' as a valid integer", + reason_code=str(DenialReason.INVALID_CONSTRAINT), + ), + reason_code=str(DenialReason.INVALID_CONSTRAINT), + cause=exc, + ): + return result + else: + constraints["max_rows"] = min(max(requested, 0), max_rows) + result.trace_steps.append( + PolicyTraceStep( + name="row_cap", + outcome="constraint_applied", + detail="max_rows capped", + ) + ) + else: + constraints["max_rows"] = max_rows + result.trace_steps.append( + PolicyTraceStep( + name="row_cap", + outcome="constraint_applied", + detail="max_rows capped", + ) + ) + + # ── Rate limiting ──────────────────────────────────────────────── + rate_key = f"{pid}:{cid}" + if capability.safety_class in self._rate_limits: + limit, window = self._rate_limits[capability.safety_class] + if "service" in roles: + limit *= SERVICE_RATE_MULTIPLIER + allowed = ( + self._limiter.peek(rate_key, limit, window) + if read_only + else self._limiter.check(rate_key, limit, window) + ) + if not allowed: + detail = ( + f"Rate limit exceeded: {limit} {capability.safety_class.value} " + f"invocations per {window}s for principal '{pid}'" + ) + add_failure( + detail=detail, + condition=FailedCondition( + condition="rate_limit", + required=f"fewer than {limit} invocations per {window}s", + actual="limit exceeded", + suggestion=( + f"Wait for the {window}s rate-limit window before retrying " + f"capability '{cid}'" + ), + reason_code=str(DenialReason.RATE_LIMITED), + ), + reason_code=str(DenialReason.RATE_LIMITED), + ) + elif not read_only: + self._limiter.record(rate_key) + + return result + + +__all__ = [ + "DefaultPolicyRuleChain", + "MAX_ROWS_SERVICE", + "MAX_ROWS_USER", + "MIN_JUSTIFICATION", + "RuleChainResult", + "RuleFailure", +] From e3f18bb0e821f088af188b0647db98a0affa1f9f Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 22:50:39 +0100 Subject: [PATCH 04/25] Add temporary verified patch workflow for #219 --- .github/workflows/agent-patch-219.yml | 254 ++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 .github/workflows/agent-patch-219.yml diff --git a/.github/workflows/agent-patch-219.yml b/.github/workflows/agent-patch-219.yml new file mode 100644 index 0000000..3a2610f --- /dev/null +++ b/.github/workflows/agent-patch-219.yml @@ -0,0 +1,254 @@ +name: Temporary agent patch for #219 + +on: + push: + branches: + - agent/unify-policy-rule-chain-219 + +permissions: + contents: write + +jobs: + patch-and-verify: + if: ${{ !contains(github.event.head_commit.message, '[agent-patch-219]') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: agent/unify-policy-rule-chain-219 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.12' + - name: Replace duplicated policy traversals with shared rule chain + run: | + python - <<'PY' + from pathlib import Path + + p = Path("src/weaver_kernel/policy.py") + text = p.read_text(encoding="utf-8") + text = text.replace("from typing import Any, Protocol\n", "from typing import Protocol\n", 1) + text = text.replace("from .enums import SafetyClass, SensitivityTag\n", "from .enums import SafetyClass\n", 1) + text = text.replace( + "from .errors import AgentKernelError, PolicyDenied\n", + "from .default_policy_rules import (\n" + " MAX_ROWS_SERVICE,\n" + " MAX_ROWS_USER,\n" + " MIN_JUSTIFICATION,\n" + " DefaultPolicyRuleChain,\n" + ")\n" + "from .errors import AgentKernelError, PolicyDenied\n", + 1, + ) + text = text.replace(" FailedCondition,\n", "", 1) + text = text.replace( + "_MIN_JUSTIFICATION = 15\n\n# Default max_rows caps.\n_MAX_ROWS_USER = 50\n_MAX_ROWS_SERVICE = 500\n", + "_MIN_JUSTIFICATION = MIN_JUSTIFICATION\n\n# Default max_rows caps.\n" + "_MAX_ROWS_USER = MAX_ROWS_USER\n_MAX_ROWS_SERVICE = MAX_ROWS_SERVICE\n", + 1, + ) + old_init = " self._rate_limits = limits\n self._limiter = RateLimiter(clock=clock)\n" + new_init = ( + " self._rate_limits = limits\n" + " self._limiter = RateLimiter(clock=clock)\n" + " self._rule_chain = DefaultPolicyRuleChain(\n" + " rate_limits=self._rate_limits, limiter=self._limiter\n" + " )\n" + ) + if text.count(old_init) != 1: + raise SystemExit(f"unexpected init marker count: {text.count(old_init)}") + text = text.replace(old_init, new_init, 1) + + class_pos = text.index("class DefaultPolicyEngine:") + evaluate_pos = text.index("\n def evaluate(\n", class_pos) + prefix = text[:evaluate_pos] + methods = r''' + def evaluate( + self, + request: CapabilityRequest, + capability: Capability, + principal: Principal, + *, + justification: str, + ) -> PolicyDecision: + """Evaluate the request against the shared default-policy rule chain. + + Decision traversal short-circuits on the first denial and may update + transient policy state (currently the sliding-window rate limiter). + ``explain()`` traverses this exact same chain in read-only mode. + """ + pid = principal.principal_id + cid = capability.capability_id + trace = PolicyDecisionTrace( + engine="DefaultPolicyEngine", + capability_id=cid, + principal_id=pid, + intent=request.intent, + scope_keys=sorted(request.scope.keys()), + ) + result = self._rule_chain.run( + request, + capability, + principal, + justification=justification, + collect_all=False, + read_only=False, + ) + trace.steps.extend(result.trace_steps) + + if result.failures: + failure = result.failures[0] + trace.steps.append( + PolicyTraceStep( + name="deny", + outcome="denied", + detail=failure.detail, + reason_code=failure.reason_code, + ) + ) + trace.final_outcome = "denied" + trace.final_reason_code = failure.reason_code + denial = self._deny( + failure.detail, + principal_id=pid, + capability_id=cid, + reason_code=failure.reason_code, + ) + if failure.cause is not None: + raise denial from failure.cause + raise denial + + reason = "Request approved by DefaultPolicyEngine." + trace.steps.append( + PolicyTraceStep( + name="allow", + outcome="allowed", + detail=reason, + reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), + ) + ) + trace.final_outcome = "allowed" + trace.final_reason_code = str(AllowReason.DEFAULT_POLICY_ALLOW) + logger.info( + "policy_allowed", + extra={ + "principal_id": pid, + "capability_id": cid, + "reason": reason, + "reason_code": str(AllowReason.DEFAULT_POLICY_ALLOW), + }, + ) + return PolicyDecision( + allowed=True, + reason=reason, + constraints=result.constraints, + reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), + trace=trace, + ) + + def explain( + self, + request: CapabilityRequest, + capability: Capability, + principal: Principal, + *, + justification: str, + ) -> DenialExplanation: + """Explain all failures from the same chain used by :meth:`evaluate`. + + Explanation is strictly read-only: it collects all failed conditions, + including the current rate-limit condition, without recording usage or + pruning/creating limiter windows. + """ + pid = principal.principal_id + cid = capability.capability_id + result = self._rule_chain.run( + request, + capability, + principal, + justification=justification, + collect_all=True, + read_only=True, + ) + failed = [failure.condition for failure in result.failures] + denied = bool(failed) + remediation = [condition.suggestion for condition in failed] + + if denied: + first = failed[0] + rule_name = ( + f"{capability.safety_class.value.lower()}-" + f"{first.condition.replace('_', '-')}" + ) + narrative = ( + f"Request for '{cid}' by '{pid}' would be denied: " + + "; ".join(condition.suggestion for condition in failed) + + "." + ) + primary_code = first.reason_code + else: + rule_name = "allowed" + narrative = ( + f"Request for '{cid}' by '{pid}' would be allowed by " + "DefaultPolicyEngine." + ) + primary_code = None + + return DenialExplanation( + denied=denied, + rule_name=rule_name, + failed_conditions=failed, + remediation=remediation, + narrative=narrative, + reason_code=primary_code, + ) +''' + p.write_text(prefix + methods, encoding="utf-8") + + p = Path("docs/architecture.md") + text = p.read_text(encoding="utf-8") + marker = ( + "Engines collect all failing conditions (no short-circuit) so callers get the full picture. " + ) + if marker not in text: + raise SystemExit("architecture explanation marker missing") + text = text.replace( + marker, + marker + + "`DefaultPolicyEngine.evaluate()` and `.explain()` are driven by one internal ordered rule chain; evaluation short-circuits and records rate usage, while explanation traverses the same rate-limit rule through a read-only `peek()` that never mutates limiter state. ", + 1, + ) + p.write_text(text, encoding="utf-8") + + p = Path("CHANGELOG.md") + text = p.read_text(encoding="utf-8") + marker = "## [Unreleased]\n" + if text.count(marker) != 1: + raise SystemExit("unexpected Unreleased heading") + text = text.replace( + marker, + marker + + "\n### Changed\n" + + "- **Default policy decisions and explanations now share one ordered rule chain (#219).** " + + "`evaluate()` short-circuits the shared chain while `explain()` collects every failure through the same rules. Rate-limit explanation uses a read-only `peek()` so it can predict a denial without consuming, creating, or pruning limiter state. Agreement and no-mutation regressions cover the security-critical boundary.\n", + 1, + ) + p.write_text(text, encoding="utf-8") + PY + git diff --check + - name: Install development dependencies + run: python -m pip install -e '.[dev]' + - name: Run focused policy regressions + run: python -m pytest -q tests/test_policy.py tests/test_policy_rule_chain.py + - name: Lint and type-check changed production code + run: | + python -m ruff check src/weaver_kernel/policy.py src/weaver_kernel/default_policy_rules.py src/weaver_kernel/rate_limit.py tests/test_policy_rule_chain.py + python -m mypy src/weaver_kernel/policy.py src/weaver_kernel/default_policy_rules.py src/weaver_kernel/rate_limit.py + - name: Commit verified patch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/weaver_kernel/policy.py docs/architecture.md CHANGELOG.md + if ! git diff --cached --quiet; then + git commit -m "[agent-patch-219] Unify default policy rule traversal" + git push origin HEAD:agent/unify-policy-rule-chain-219 + fi From 6c3c040f6fbb42720f237c3ac431f7771b6347e7 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 22:53:00 +0100 Subject: [PATCH 05/25] Add temporary policy method patch payload --- .github/agent-policy-methods-219.txt | 140 +++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 .github/agent-policy-methods-219.txt diff --git a/.github/agent-policy-methods-219.txt b/.github/agent-policy-methods-219.txt new file mode 100644 index 0000000..9c50ba9 --- /dev/null +++ b/.github/agent-policy-methods-219.txt @@ -0,0 +1,140 @@ + + def evaluate( + self, + request: CapabilityRequest, + capability: Capability, + principal: Principal, + *, + justification: str, + ) -> PolicyDecision: + """Evaluate the request against the shared default-policy rule chain. + + Decision traversal short-circuits on the first denial and may update + transient policy state (currently the sliding-window rate limiter). + ``explain()`` traverses this exact same chain in read-only mode. + """ + pid = principal.principal_id + cid = capability.capability_id + trace = PolicyDecisionTrace( + engine="DefaultPolicyEngine", + capability_id=cid, + principal_id=pid, + intent=request.intent, + scope_keys=sorted(request.scope.keys()), + ) + result = self._rule_chain.run( + request, + capability, + principal, + justification=justification, + collect_all=False, + read_only=False, + ) + trace.steps.extend(result.trace_steps) + + if result.failures: + failure = result.failures[0] + trace.steps.append( + PolicyTraceStep( + name="deny", + outcome="denied", + detail=failure.detail, + reason_code=failure.reason_code, + ) + ) + trace.final_outcome = "denied" + trace.final_reason_code = failure.reason_code + denial = self._deny( + failure.detail, + principal_id=pid, + capability_id=cid, + reason_code=failure.reason_code, + ) + if failure.cause is not None: + raise denial from failure.cause + raise denial + + reason = "Request approved by DefaultPolicyEngine." + trace.steps.append( + PolicyTraceStep( + name="allow", + outcome="allowed", + detail=reason, + reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), + ) + ) + trace.final_outcome = "allowed" + trace.final_reason_code = str(AllowReason.DEFAULT_POLICY_ALLOW) + logger.info( + "policy_allowed", + extra={ + "principal_id": pid, + "capability_id": cid, + "reason": reason, + "reason_code": str(AllowReason.DEFAULT_POLICY_ALLOW), + }, + ) + return PolicyDecision( + allowed=True, + reason=reason, + constraints=result.constraints, + reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), + trace=trace, + ) + + def explain( + self, + request: CapabilityRequest, + capability: Capability, + principal: Principal, + *, + justification: str, + ) -> DenialExplanation: + """Explain all failures from the same chain used by :meth:`evaluate`. + + Explanation is strictly read-only: it collects all failed conditions, + including the current rate-limit condition, without recording usage or + pruning/creating limiter windows. + """ + pid = principal.principal_id + cid = capability.capability_id + result = self._rule_chain.run( + request, + capability, + principal, + justification=justification, + collect_all=True, + read_only=True, + ) + failed = [failure.condition for failure in result.failures] + denied = bool(failed) + remediation = [condition.suggestion for condition in failed] + + if denied: + first = failed[0] + rule_name = ( + f"{capability.safety_class.value.lower()}-" + f"{first.condition.replace('_', '-')}" + ) + narrative = ( + f"Request for '{cid}' by '{pid}' would be denied: " + + "; ".join(condition.suggestion for condition in failed) + + "." + ) + primary_code = first.reason_code + else: + rule_name = "allowed" + narrative = ( + f"Request for '{cid}' by '{pid}' would be allowed by " + "DefaultPolicyEngine." + ) + primary_code = None + + return DenialExplanation( + denied=denied, + rule_name=rule_name, + failed_conditions=failed, + remediation=remediation, + narrative=narrative, + reason_code=primary_code, + ) From bebb085919402e0e1da1530ae1b4693dd03e2c7e Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 22:53:18 +0100 Subject: [PATCH 06/25] Fix temporary #219 patch workflow syntax --- .github/workflows/agent-patch-219.yml | 158 +------------------------- 1 file changed, 5 insertions(+), 153 deletions(-) diff --git a/.github/workflows/agent-patch-219.yml b/.github/workflows/agent-patch-219.yml index 3a2610f..fb8495f 100644 --- a/.github/workflows/agent-patch-219.yml +++ b/.github/workflows/agent-patch-219.yml @@ -57,164 +57,19 @@ jobs: if text.count(old_init) != 1: raise SystemExit(f"unexpected init marker count: {text.count(old_init)}") text = text.replace(old_init, new_init, 1) - class_pos = text.index("class DefaultPolicyEngine:") evaluate_pos = text.index("\n def evaluate(\n", class_pos) - prefix = text[:evaluate_pos] - methods = r''' - def evaluate( - self, - request: CapabilityRequest, - capability: Capability, - principal: Principal, - *, - justification: str, - ) -> PolicyDecision: - """Evaluate the request against the shared default-policy rule chain. - - Decision traversal short-circuits on the first denial and may update - transient policy state (currently the sliding-window rate limiter). - ``explain()`` traverses this exact same chain in read-only mode. - """ - pid = principal.principal_id - cid = capability.capability_id - trace = PolicyDecisionTrace( - engine="DefaultPolicyEngine", - capability_id=cid, - principal_id=pid, - intent=request.intent, - scope_keys=sorted(request.scope.keys()), - ) - result = self._rule_chain.run( - request, - capability, - principal, - justification=justification, - collect_all=False, - read_only=False, - ) - trace.steps.extend(result.trace_steps) - - if result.failures: - failure = result.failures[0] - trace.steps.append( - PolicyTraceStep( - name="deny", - outcome="denied", - detail=failure.detail, - reason_code=failure.reason_code, - ) - ) - trace.final_outcome = "denied" - trace.final_reason_code = failure.reason_code - denial = self._deny( - failure.detail, - principal_id=pid, - capability_id=cid, - reason_code=failure.reason_code, - ) - if failure.cause is not None: - raise denial from failure.cause - raise denial - - reason = "Request approved by DefaultPolicyEngine." - trace.steps.append( - PolicyTraceStep( - name="allow", - outcome="allowed", - detail=reason, - reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), - ) - ) - trace.final_outcome = "allowed" - trace.final_reason_code = str(AllowReason.DEFAULT_POLICY_ALLOW) - logger.info( - "policy_allowed", - extra={ - "principal_id": pid, - "capability_id": cid, - "reason": reason, - "reason_code": str(AllowReason.DEFAULT_POLICY_ALLOW), - }, - ) - return PolicyDecision( - allowed=True, - reason=reason, - constraints=result.constraints, - reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), - trace=trace, - ) - - def explain( - self, - request: CapabilityRequest, - capability: Capability, - principal: Principal, - *, - justification: str, - ) -> DenialExplanation: - """Explain all failures from the same chain used by :meth:`evaluate`. - - Explanation is strictly read-only: it collects all failed conditions, - including the current rate-limit condition, without recording usage or - pruning/creating limiter windows. - """ - pid = principal.principal_id - cid = capability.capability_id - result = self._rule_chain.run( - request, - capability, - principal, - justification=justification, - collect_all=True, - read_only=True, - ) - failed = [failure.condition for failure in result.failures] - denied = bool(failed) - remediation = [condition.suggestion for condition in failed] - - if denied: - first = failed[0] - rule_name = ( - f"{capability.safety_class.value.lower()}-" - f"{first.condition.replace('_', '-')}" - ) - narrative = ( - f"Request for '{cid}' by '{pid}' would be denied: " - + "; ".join(condition.suggestion for condition in failed) - + "." - ) - primary_code = first.reason_code - else: - rule_name = "allowed" - narrative = ( - f"Request for '{cid}' by '{pid}' would be allowed by " - "DefaultPolicyEngine." - ) - primary_code = None - - return DenialExplanation( - denied=denied, - rule_name=rule_name, - failed_conditions=failed, - remediation=remediation, - narrative=narrative, - reason_code=primary_code, - ) -''' - p.write_text(prefix + methods, encoding="utf-8") + methods = Path(".github/agent-policy-methods-219.txt").read_text(encoding="utf-8") + p.write_text(text[:evaluate_pos] + methods, encoding="utf-8") p = Path("docs/architecture.md") text = p.read_text(encoding="utf-8") - marker = ( - "Engines collect all failing conditions (no short-circuit) so callers get the full picture. " - ) + marker = "Engines collect all failing conditions (no short-circuit) so callers get the full picture. " if marker not in text: raise SystemExit("architecture explanation marker missing") text = text.replace( marker, - marker - + "`DefaultPolicyEngine.evaluate()` and `.explain()` are driven by one internal ordered rule chain; evaluation short-circuits and records rate usage, while explanation traverses the same rate-limit rule through a read-only `peek()` that never mutates limiter state. ", + marker + "`DefaultPolicyEngine.evaluate()` and `.explain()` are driven by one internal ordered rule chain; evaluation short-circuits and records rate usage, while explanation traverses the same rate-limit rule through a read-only `peek()` that never mutates limiter state. ", 1, ) p.write_text(text, encoding="utf-8") @@ -226,10 +81,7 @@ jobs: raise SystemExit("unexpected Unreleased heading") text = text.replace( marker, - marker - + "\n### Changed\n" - + "- **Default policy decisions and explanations now share one ordered rule chain (#219).** " - + "`evaluate()` short-circuits the shared chain while `explain()` collects every failure through the same rules. Rate-limit explanation uses a read-only `peek()` so it can predict a denial without consuming, creating, or pruning limiter state. Agreement and no-mutation regressions cover the security-critical boundary.\n", + marker + "\n### Changed\n- **Default policy decisions and explanations now share one ordered rule chain (#219).** `evaluate()` short-circuits the shared chain while `explain()` collects every failure through the same rules. Rate-limit explanation uses a read-only `peek()` so it can predict a denial without consuming, creating, or pruning limiter state. Agreement and no-mutation regressions cover the security-critical boundary.\n", 1, ) p.write_text(text, encoding="utf-8") From 6733ff38764a9e7a6797509d6ffd50f267869b27 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 22:56:24 +0100 Subject: [PATCH 07/25] Avoid reserved pytest request parameter --- tests/test_policy_rule_chain.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_policy_rule_chain.py b/tests/test_policy_rule_chain.py index f3ce7df..a1a1cf8 100644 --- a/tests/test_policy_rule_chain.py +++ b/tests/test_policy_rule_chain.py @@ -140,11 +140,18 @@ def _request(*, max_rows: object | None = None, memory_scope: str | None = None) @pytest.mark.parametrize( - ("request", "capability", "principal", "justification", "denied", "reason_code"), + ( + "cap_request", + "capability", + "principal", + "justification", + "denied", + "reason_code", + ), _CASES, ) def test_explain_prediction_matches_evaluate( - request: CapabilityRequest, + cap_request: CapabilityRequest, capability: Capability, principal: Principal, justification: str, @@ -154,7 +161,7 @@ def test_explain_prediction_matches_evaluate( engine = DefaultPolicyEngine() explanation = engine.explain( - request, + cap_request, capability, principal, justification=justification, @@ -162,7 +169,7 @@ def test_explain_prediction_matches_evaluate( try: decision = engine.evaluate( - request, + cap_request, capability, principal, justification=justification, From 2c37c4b8ab2875de2cf5817fb169575e07be976e Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 22:58:40 +0100 Subject: [PATCH 08/25] Let Ruff normalize generated #219 imports --- .github/workflows/agent-patch-219.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/agent-patch-219.yml b/.github/workflows/agent-patch-219.yml index fb8495f..f40fe46 100644 --- a/.github/workflows/agent-patch-219.yml +++ b/.github/workflows/agent-patch-219.yml @@ -93,6 +93,7 @@ jobs: run: python -m pytest -q tests/test_policy.py tests/test_policy_rule_chain.py - name: Lint and type-check changed production code run: | + python -m ruff check --fix src/weaver_kernel/policy.py src/weaver_kernel/default_policy_rules.py src/weaver_kernel/rate_limit.py tests/test_policy_rule_chain.py python -m ruff check src/weaver_kernel/policy.py src/weaver_kernel/default_policy_rules.py src/weaver_kernel/rate_limit.py tests/test_policy_rule_chain.py python -m mypy src/weaver_kernel/policy.py src/weaver_kernel/default_policy_rules.py src/weaver_kernel/rate_limit.py - name: Commit verified patch From 853f8480e040d62cfb4cf3cca7f43d5dc91d9728 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:59:48 +0000 Subject: [PATCH 09/25] [agent-patch-219] Unify default policy rule traversal --- CHANGELOG.md | 3 + docs/architecture.md | 2 +- src/weaver_kernel/policy.py | 452 +++++------------------------------- 3 files changed, 67 insertions(+), 390 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2425c..9f7b858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Default policy decisions and explanations now share one ordered rule chain (#219).** `evaluate()` short-circuits the shared chain while `explain()` collects every failure through the same rules. Rate-limit explanation uses a read-only `peek()` so it can predict a denial without consuming, creating, or pruning limiter state. Agreement and no-mutation regressions cover the security-critical boundary. + ## [0.12.0] - 2026-08-14 ### Changed diff --git a/docs/architecture.md b/docs/architecture.md index 35668fe..00899aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,7 +99,7 @@ Intent-aware rules fail closed: a request with `intent=None` never matches a rul #### Denial explanations -`PolicyEngine.explain()` (when available) returns a structured `DenialExplanation` with `denied`, `rule_name`, a `failed_conditions: list[FailedCondition]` describing each missing condition with `required`/`actual`/`suggestion`/`reason_code`, a `remediation` list, a human-readable `narrative`, and a top-level `reason_code` (the code of the first failed condition). Engines collect all failing conditions (no short-circuit) so callers get the full picture. For `DeclarativePolicyEngine`, an explicit deny rule that fully matches is reported as the cause; partial-match deny rules are skipped during explanation so the surfaced advice is actionable rather than self-defeating. +`PolicyEngine.explain()` (when available) returns a structured `DenialExplanation` with `denied`, `rule_name`, a `failed_conditions: list[FailedCondition]` describing each missing condition with `required`/`actual`/`suggestion`/`reason_code`, a `remediation` list, a human-readable `narrative`, and a top-level `reason_code` (the code of the first failed condition). Engines collect all failing conditions (no short-circuit) so callers get the full picture. `DefaultPolicyEngine.evaluate()` and `.explain()` are driven by one internal ordered rule chain; evaluation short-circuits and records rate usage, while explanation traverses the same rate-limit rule through a read-only `peek()` that never mutates limiter state. For `DeclarativePolicyEngine`, an explicit deny rule that fully matches is reported as the cause; partial-match deny rules are skipped during explanation so the surfaced advice is actionable rather than self-defeating. #### Reason codes diff --git a/src/weaver_kernel/policy.py b/src/weaver_kernel/policy.py index 2d7dde3..8a708a4 100644 --- a/src/weaver_kernel/policy.py +++ b/src/weaver_kernel/policy.py @@ -4,31 +4,36 @@ import logging from collections.abc import Callable -from typing import Any, Protocol +from typing import Protocol -from .enums import SafetyClass, SensitivityTag +from .default_policy_rules import ( + MAX_ROWS_SERVICE, + MAX_ROWS_USER, + MIN_JUSTIFICATION, + DefaultPolicyRuleChain, +) +from .enums import SafetyClass from .errors import AgentKernelError, PolicyDenied from .models import ( Capability, CapabilityRequest, DenialExplanation, - FailedCondition, PolicyDecision, PolicyDecisionTrace, PolicyTraceStep, Principal, ) -from .policy_reasons import AllowReason, DenialReason +from .policy_reasons import AllowReason from .rate_limit import DEFAULT_RATE_LIMITS, SERVICE_RATE_MULTIPLIER, RateLimiter logger = logging.getLogger(__name__) # Minimum justification length for WRITE operations. -_MIN_JUSTIFICATION = 15 +_MIN_JUSTIFICATION = MIN_JUSTIFICATION # Default max_rows caps. -_MAX_ROWS_USER = 50 -_MAX_ROWS_SERVICE = 500 +_MAX_ROWS_USER = MAX_ROWS_USER +_MAX_ROWS_SERVICE = MAX_ROWS_SERVICE # Backwards-compatible aliases — these used to be defined here. New code # should import the names without the leading underscore from ``rate_limit``. @@ -156,6 +161,9 @@ def __init__( ) self._rate_limits = limits self._limiter = RateLimiter(clock=clock) + self._rule_chain = DefaultPolicyRuleChain( + rate_limits=self._rate_limits, limiter=self._limiter + ) @staticmethod def _deny( @@ -185,27 +193,14 @@ def evaluate( *, justification: str, ) -> PolicyDecision: - """Evaluate the request against the default policy rules. - - Args: - request: The capability request being evaluated. - capability: The target capability. - principal: The requesting principal. - justification: Free-text justification from the caller. - - Returns: - :class:`PolicyDecision` with ``allowed=True`` and any imposed - constraints, or raises :class:`PolicyDenied`. + """Evaluate the request against the shared default-policy rule chain. - Raises: - PolicyDenied: When the request violates a policy rule. + Decision traversal short-circuits on the first denial and may update + transient policy state (currently the sliding-window rate limiter). + ``explain()`` traverses this exact same chain in read-only mode. """ - roles = set(principal.roles) - constraints: dict[str, Any] = dict(request.constraints) - pid = principal.principal_id cid = capability.capability_id - trace = PolicyDecisionTrace( engine="DefaultPolicyEngine", capability_id=cid, @@ -213,227 +208,37 @@ def evaluate( intent=request.intent, scope_keys=sorted(request.scope.keys()), ) + result = self._rule_chain.run( + request, + capability, + principal, + justification=justification, + collect_all=False, + read_only=False, + ) + trace.steps.extend(result.trace_steps) - def _record_deny(detail: str, code: str) -> None: + if result.failures: + failure = result.failures[0] trace.steps.append( PolicyTraceStep( name="deny", outcome="denied", - detail=detail, - reason_code=code, + detail=failure.detail, + reason_code=failure.reason_code, ) ) trace.final_outcome = "denied" - trace.final_reason_code = code - - # ── Safety class checks ─────────────────────────────────────────────── - - if capability.safety_class == SafetyClass.WRITE: - if not (roles & {"writer", "admin"}): - detail = ( - f"WRITE capabilities require the 'writer' or 'admin' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MISSING_ROLE) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MISSING_ROLE, - ) - stripped_len = len(justification.strip()) - if stripped_len < _MIN_JUSTIFICATION: - detail = ( - f"WRITE capabilities require a justification of at least " - f"{_MIN_JUSTIFICATION} characters. " - f"Got {len(justification)} characters " - f"({stripped_len} after trimming whitespace)." - ) - _record_deny(detail, DenialReason.INSUFFICIENT_JUSTIFICATION) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.INSUFFICIENT_JUSTIFICATION, - ) - - elif capability.safety_class == SafetyClass.DESTRUCTIVE: - if "admin" not in roles: - detail = ( - f"DESTRUCTIVE capabilities require the 'admin' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MISSING_ROLE) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MISSING_ROLE, - ) - stripped_len = len(justification.strip()) - if stripped_len < _MIN_JUSTIFICATION: - detail = ( - f"DESTRUCTIVE capabilities require a justification of at least " - f"{_MIN_JUSTIFICATION} characters. " - f"Got {len(justification)} characters " - f"({stripped_len} after trimming whitespace)." - ) - _record_deny(detail, DenialReason.INSUFFICIENT_JUSTIFICATION) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.INSUFFICIENT_JUSTIFICATION, - ) - - # ── Sensitivity checks ──────────────────────────────────────────────── - - if capability.sensitivity in (SensitivityTag.PII, SensitivityTag.PCI): - if "tenant" not in principal.attributes: - detail = ( - f"Capability '{cid}' has " - f"{capability.sensitivity.value} sensitivity and requires " - "the principal to have a 'tenant' attribute." - ) - _record_deny(detail, DenialReason.MISSING_TENANT_ATTRIBUTE) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MISSING_TENANT_ATTRIBUTE, - ) - # Enforce allowed_fields unless the principal is a pii_reader. - if capability.allowed_fields and "pii_reader" not in roles: - constraints["allowed_fields"] = capability.allowed_fields - trace.steps.append( - PolicyTraceStep( - name="sensitivity:allowed_fields", - outcome="constraint_applied", - detail=f"applied allowed_fields={capability.allowed_fields}", - ) - ) - - if capability.sensitivity == SensitivityTag.SECRETS: - if not (roles & {"admin", "secrets_reader"}): - detail = ( - f"SECRETS capabilities require the 'admin' or 'secrets_reader' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MISSING_ROLE) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MISSING_ROLE, - ) - stripped_len = len(justification.strip()) - if stripped_len < _MIN_JUSTIFICATION: - detail = ( - f"SECRETS capabilities require a justification of at least " - f"{_MIN_JUSTIFICATION} characters. " - f"Got {len(justification)} characters " - f"({stripped_len} after trimming whitespace)." - ) - _record_deny(detail, DenialReason.INSUFFICIENT_JUSTIFICATION) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.INSUFFICIENT_JUSTIFICATION, - ) - - # ── Memory action checks ───────────────────────────────────────────── - # Placed AFTER all other sensitivity checks (see invariants.md: - # "rule placement matters"). Memory reads at scope == "sensitive" - # require an explicit reader role; memory writes are treated as - # higher-risk than reads because they persist into future sessions - # and require the 'memory_writer' role (or 'admin'). - if capability.sensitivity == SensitivityTag.MEMORY: - memory_scope = str(request.scope.get("memory_scope", "")) if request.scope else "" - is_write = capability.safety_class in ( - SafetyClass.WRITE, - SafetyClass.DESTRUCTIVE, - ) - if is_write and not (roles & {"memory_writer", "admin"}): - detail = ( - f"MEMORY write capabilities require the 'memory_writer' or " - f"'admin' role. Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MEMORY_WRITE_REQUIRES_WRITER) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MEMORY_WRITE_REQUIRES_WRITER, - ) - if ( - not is_write - and memory_scope == "sensitive" - and not (roles & {"memory_reader_sensitive", "admin"}) - ): - detail = ( - f"MEMORY read with scope='sensitive' requires the " - f"'memory_reader_sensitive' or 'admin' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - _record_deny(detail, DenialReason.MEMORY_SENSITIVE_READ_DENIED) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.MEMORY_SENSITIVE_READ_DENIED, - ) - - # ── Row cap ─────────────────────────────────────────────────────────── - - max_rows = _MAX_ROWS_SERVICE if "service" in roles else _MAX_ROWS_USER - # Respect any tighter constraint from the request itself. - if "max_rows" in constraints: - try: - requested = int(constraints["max_rows"]) - except (TypeError, ValueError) as exc: - detail = ( - f"Invalid 'max_rows' constraint: {constraints['max_rows']!r} " - "is not a valid integer." - ) - _record_deny(detail, DenialReason.INVALID_CONSTRAINT) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.INVALID_CONSTRAINT, - ) from exc - constraints["max_rows"] = min(max(requested, 0), max_rows) - else: - constraints["max_rows"] = max_rows - trace.steps.append( - PolicyTraceStep( - name="row_cap", - outcome="constraint_applied", - detail="max_rows capped", + trace.final_reason_code = failure.reason_code + denial = self._deny( + failure.detail, + principal_id=pid, + capability_id=cid, + reason_code=failure.reason_code, ) - ) - - # ── Rate limiting ───────────────────────────────────────────────── - - rate_key = f"{pid}:{cid}" - if capability.safety_class in self._rate_limits: - limit, window = self._rate_limits[capability.safety_class] - if "service" in roles: - limit *= _SERVICE_RATE_MULTIPLIER - if not self._limiter.check(rate_key, limit, window): - detail = ( - f"Rate limit exceeded: {limit} {capability.safety_class.value} " - f"invocations per {window}s for principal '{pid}'" - ) - _record_deny(detail, DenialReason.RATE_LIMITED) - raise self._deny( - detail, - principal_id=pid, - capability_id=cid, - reason_code=DenialReason.RATE_LIMITED, - ) - self._limiter.record(rate_key) + if failure.cause is not None: + raise denial from failure.cause + raise denial reason = "Request approved by DefaultPolicyEngine." trace.steps.append( @@ -458,7 +263,7 @@ def _record_deny(detail: str, code: str) -> None: return PolicyDecision( allowed=True, reason=reason, - constraints=constraints, + constraints=result.constraints, reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), trace=trace, ) @@ -471,175 +276,44 @@ def explain( *, justification: str, ) -> DenialExplanation: - """Explain which policy conditions would deny *principal*'s *request*. - - Traverses the same rule chain as :meth:`evaluate` but collects ALL - failing conditions instead of short-circuiting on the first failure. - Rate-limit state is excluded — it is transient and not remediable - by changing the request. + """Explain all failures from the same chain used by :meth:`evaluate`. - Args: - request: The capability request to explain. - capability: The target capability. - principal: The requesting principal. - justification: Free-text justification from the caller. - - Returns: - :class:`DenialExplanation` with ``denied=False`` if allowed. + Explanation is strictly read-only: it collects all failed conditions, + including the current rate-limit condition, without recording usage or + pruning/creating limiter windows. """ - roles = set(principal.roles) pid = principal.principal_id cid = capability.capability_id - failed: list[FailedCondition] = [] - - # ── Safety class checks ─────────────────────────────────────────────── - - if capability.safety_class == SafetyClass.WRITE: - if not (roles & {"writer", "admin"}): - failed.append( - FailedCondition( - condition="roles", - required=["writer", "admin"], - actual=sorted(roles), - suggestion=f"Add 'writer' or 'admin' role to principal '{pid}'", - reason_code=str(DenialReason.MISSING_ROLE), - ) - ) - stripped = len(justification.strip()) - if stripped < _MIN_JUSTIFICATION: - failed.append( - FailedCondition( - condition="min_justification", - required=_MIN_JUSTIFICATION, - actual=stripped, - suggestion=( - f"Provide justification with at least {_MIN_JUSTIFICATION} " - f"characters (currently {stripped})" - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ) - ) - - elif capability.safety_class == SafetyClass.DESTRUCTIVE: - if "admin" not in roles: - failed.append( - FailedCondition( - condition="roles", - required=["admin"], - actual=sorted(roles), - suggestion=f"Add 'admin' role to principal '{pid}'", - reason_code=str(DenialReason.MISSING_ROLE), - ) - ) - stripped = len(justification.strip()) - if stripped < _MIN_JUSTIFICATION: - failed.append( - FailedCondition( - condition="min_justification", - required=_MIN_JUSTIFICATION, - actual=stripped, - suggestion=( - f"Provide justification with at least {_MIN_JUSTIFICATION} " - f"characters (currently {stripped})" - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ) - ) - - # ── Sensitivity checks ──────────────────────────────────────────────── - - if ( - capability.sensitivity in (SensitivityTag.PII, SensitivityTag.PCI) - and "tenant" not in principal.attributes - ): - failed.append( - FailedCondition( - condition="tenant_attribute", - required="present", - actual="absent", - suggestion=f"Add 'tenant' attribute to principal '{pid}'", - reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), - ) - ) - - if capability.sensitivity == SensitivityTag.SECRETS: - if not (roles & {"admin", "secrets_reader"}): - failed.append( - FailedCondition( - condition="roles", - required=["admin", "secrets_reader"], - actual=sorted(roles), - suggestion=f"Add 'admin' or 'secrets_reader' role to principal '{pid}'", - reason_code=str(DenialReason.MISSING_ROLE), - ) - ) - stripped = len(justification.strip()) - if stripped < _MIN_JUSTIFICATION: - failed.append( - FailedCondition( - condition="min_justification", - required=_MIN_JUSTIFICATION, - actual=stripped, - suggestion=( - f"Provide justification with at least {_MIN_JUSTIFICATION} " - f"characters (currently {stripped})" - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ) - ) - - if capability.sensitivity == SensitivityTag.MEMORY: - memory_scope = str(request.scope.get("memory_scope", "")) if request.scope else "" - is_write = capability.safety_class in ( - SafetyClass.WRITE, - SafetyClass.DESTRUCTIVE, - ) - if is_write and not (roles & {"memory_writer", "admin"}): - failed.append( - FailedCondition( - condition="roles", - required=["memory_writer", "admin"], - actual=sorted(roles), - suggestion=(f"Add 'memory_writer' or 'admin' role to principal '{pid}'"), - reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), - ) - ) - if ( - not is_write - and memory_scope == "sensitive" - and not (roles & {"memory_reader_sensitive", "admin"}) - ): - failed.append( - FailedCondition( - condition="roles", - required=["memory_reader_sensitive", "admin"], - actual=sorted(roles), - suggestion=( - f"Add 'memory_reader_sensitive' or 'admin' role to " - f"principal '{pid}' (or narrow the request scope away " - f"from 'sensitive')" - ), - reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), - ) - ) - + result = self._rule_chain.run( + request, + capability, + principal, + justification=justification, + collect_all=True, + read_only=True, + ) + failed = [failure.condition for failure in result.failures] denied = bool(failed) - remediation = [fc.suggestion for fc in failed] + remediation = [condition.suggestion for condition in failed] if denied: first = failed[0] rule_name = ( - f"{capability.safety_class.value.lower()}-{first.condition.replace('_', '-')}" + f"{capability.safety_class.value.lower()}-" + f"{first.condition.replace('_', '-')}" ) narrative = ( f"Request for '{cid}' by '{pid}' would be denied: " - + "; ".join(fc.suggestion for fc in failed) + + "; ".join(condition.suggestion for condition in failed) + "." ) primary_code = first.reason_code else: rule_name = "allowed" - narrative = f"Request for '{cid}' by '{pid}' would be allowed by DefaultPolicyEngine." + narrative = ( + f"Request for '{cid}' by '{pid}' would be allowed by " + "DefaultPolicyEngine." + ) primary_code = None return DenialExplanation( From 926a909432bde501a81ba32a5edb91f98aa1aa8e Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:00:24 +0100 Subject: [PATCH 10/25] Remove temporary #219 patch workflow --- .github/workflows/agent-patch-219.yml | 107 -------------------------- 1 file changed, 107 deletions(-) delete mode 100644 .github/workflows/agent-patch-219.yml diff --git a/.github/workflows/agent-patch-219.yml b/.github/workflows/agent-patch-219.yml deleted file mode 100644 index f40fe46..0000000 --- a/.github/workflows/agent-patch-219.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Temporary agent patch for #219 - -on: - push: - branches: - - agent/unify-policy-rule-chain-219 - -permissions: - contents: write - -jobs: - patch-and-verify: - if: ${{ !contains(github.event.head_commit.message, '[agent-patch-219]') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: agent/unify-policy-rule-chain-219 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: '3.12' - - name: Replace duplicated policy traversals with shared rule chain - run: | - python - <<'PY' - from pathlib import Path - - p = Path("src/weaver_kernel/policy.py") - text = p.read_text(encoding="utf-8") - text = text.replace("from typing import Any, Protocol\n", "from typing import Protocol\n", 1) - text = text.replace("from .enums import SafetyClass, SensitivityTag\n", "from .enums import SafetyClass\n", 1) - text = text.replace( - "from .errors import AgentKernelError, PolicyDenied\n", - "from .default_policy_rules import (\n" - " MAX_ROWS_SERVICE,\n" - " MAX_ROWS_USER,\n" - " MIN_JUSTIFICATION,\n" - " DefaultPolicyRuleChain,\n" - ")\n" - "from .errors import AgentKernelError, PolicyDenied\n", - 1, - ) - text = text.replace(" FailedCondition,\n", "", 1) - text = text.replace( - "_MIN_JUSTIFICATION = 15\n\n# Default max_rows caps.\n_MAX_ROWS_USER = 50\n_MAX_ROWS_SERVICE = 500\n", - "_MIN_JUSTIFICATION = MIN_JUSTIFICATION\n\n# Default max_rows caps.\n" - "_MAX_ROWS_USER = MAX_ROWS_USER\n_MAX_ROWS_SERVICE = MAX_ROWS_SERVICE\n", - 1, - ) - old_init = " self._rate_limits = limits\n self._limiter = RateLimiter(clock=clock)\n" - new_init = ( - " self._rate_limits = limits\n" - " self._limiter = RateLimiter(clock=clock)\n" - " self._rule_chain = DefaultPolicyRuleChain(\n" - " rate_limits=self._rate_limits, limiter=self._limiter\n" - " )\n" - ) - if text.count(old_init) != 1: - raise SystemExit(f"unexpected init marker count: {text.count(old_init)}") - text = text.replace(old_init, new_init, 1) - class_pos = text.index("class DefaultPolicyEngine:") - evaluate_pos = text.index("\n def evaluate(\n", class_pos) - methods = Path(".github/agent-policy-methods-219.txt").read_text(encoding="utf-8") - p.write_text(text[:evaluate_pos] + methods, encoding="utf-8") - - p = Path("docs/architecture.md") - text = p.read_text(encoding="utf-8") - marker = "Engines collect all failing conditions (no short-circuit) so callers get the full picture. " - if marker not in text: - raise SystemExit("architecture explanation marker missing") - text = text.replace( - marker, - marker + "`DefaultPolicyEngine.evaluate()` and `.explain()` are driven by one internal ordered rule chain; evaluation short-circuits and records rate usage, while explanation traverses the same rate-limit rule through a read-only `peek()` that never mutates limiter state. ", - 1, - ) - p.write_text(text, encoding="utf-8") - - p = Path("CHANGELOG.md") - text = p.read_text(encoding="utf-8") - marker = "## [Unreleased]\n" - if text.count(marker) != 1: - raise SystemExit("unexpected Unreleased heading") - text = text.replace( - marker, - marker + "\n### Changed\n- **Default policy decisions and explanations now share one ordered rule chain (#219).** `evaluate()` short-circuits the shared chain while `explain()` collects every failure through the same rules. Rate-limit explanation uses a read-only `peek()` so it can predict a denial without consuming, creating, or pruning limiter state. Agreement and no-mutation regressions cover the security-critical boundary.\n", - 1, - ) - p.write_text(text, encoding="utf-8") - PY - git diff --check - - name: Install development dependencies - run: python -m pip install -e '.[dev]' - - name: Run focused policy regressions - run: python -m pytest -q tests/test_policy.py tests/test_policy_rule_chain.py - - name: Lint and type-check changed production code - run: | - python -m ruff check --fix src/weaver_kernel/policy.py src/weaver_kernel/default_policy_rules.py src/weaver_kernel/rate_limit.py tests/test_policy_rule_chain.py - python -m ruff check src/weaver_kernel/policy.py src/weaver_kernel/default_policy_rules.py src/weaver_kernel/rate_limit.py tests/test_policy_rule_chain.py - python -m mypy src/weaver_kernel/policy.py src/weaver_kernel/default_policy_rules.py src/weaver_kernel/rate_limit.py - - name: Commit verified patch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/weaver_kernel/policy.py docs/architecture.md CHANGELOG.md - if ! git diff --cached --quiet; then - git commit -m "[agent-patch-219] Unify default policy rule traversal" - git push origin HEAD:agent/unify-policy-rule-chain-219 - fi From da78b0d259353806ee5a0d636e71980dc85ff45a Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:00:31 +0100 Subject: [PATCH 11/25] Remove temporary #219 patch payload --- .github/agent-policy-methods-219.txt | 140 --------------------------- 1 file changed, 140 deletions(-) delete mode 100644 .github/agent-policy-methods-219.txt diff --git a/.github/agent-policy-methods-219.txt b/.github/agent-policy-methods-219.txt deleted file mode 100644 index 9c50ba9..0000000 --- a/.github/agent-policy-methods-219.txt +++ /dev/null @@ -1,140 +0,0 @@ - - def evaluate( - self, - request: CapabilityRequest, - capability: Capability, - principal: Principal, - *, - justification: str, - ) -> PolicyDecision: - """Evaluate the request against the shared default-policy rule chain. - - Decision traversal short-circuits on the first denial and may update - transient policy state (currently the sliding-window rate limiter). - ``explain()`` traverses this exact same chain in read-only mode. - """ - pid = principal.principal_id - cid = capability.capability_id - trace = PolicyDecisionTrace( - engine="DefaultPolicyEngine", - capability_id=cid, - principal_id=pid, - intent=request.intent, - scope_keys=sorted(request.scope.keys()), - ) - result = self._rule_chain.run( - request, - capability, - principal, - justification=justification, - collect_all=False, - read_only=False, - ) - trace.steps.extend(result.trace_steps) - - if result.failures: - failure = result.failures[0] - trace.steps.append( - PolicyTraceStep( - name="deny", - outcome="denied", - detail=failure.detail, - reason_code=failure.reason_code, - ) - ) - trace.final_outcome = "denied" - trace.final_reason_code = failure.reason_code - denial = self._deny( - failure.detail, - principal_id=pid, - capability_id=cid, - reason_code=failure.reason_code, - ) - if failure.cause is not None: - raise denial from failure.cause - raise denial - - reason = "Request approved by DefaultPolicyEngine." - trace.steps.append( - PolicyTraceStep( - name="allow", - outcome="allowed", - detail=reason, - reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), - ) - ) - trace.final_outcome = "allowed" - trace.final_reason_code = str(AllowReason.DEFAULT_POLICY_ALLOW) - logger.info( - "policy_allowed", - extra={ - "principal_id": pid, - "capability_id": cid, - "reason": reason, - "reason_code": str(AllowReason.DEFAULT_POLICY_ALLOW), - }, - ) - return PolicyDecision( - allowed=True, - reason=reason, - constraints=result.constraints, - reason_code=str(AllowReason.DEFAULT_POLICY_ALLOW), - trace=trace, - ) - - def explain( - self, - request: CapabilityRequest, - capability: Capability, - principal: Principal, - *, - justification: str, - ) -> DenialExplanation: - """Explain all failures from the same chain used by :meth:`evaluate`. - - Explanation is strictly read-only: it collects all failed conditions, - including the current rate-limit condition, without recording usage or - pruning/creating limiter windows. - """ - pid = principal.principal_id - cid = capability.capability_id - result = self._rule_chain.run( - request, - capability, - principal, - justification=justification, - collect_all=True, - read_only=True, - ) - failed = [failure.condition for failure in result.failures] - denied = bool(failed) - remediation = [condition.suggestion for condition in failed] - - if denied: - first = failed[0] - rule_name = ( - f"{capability.safety_class.value.lower()}-" - f"{first.condition.replace('_', '-')}" - ) - narrative = ( - f"Request for '{cid}' by '{pid}' would be denied: " - + "; ".join(condition.suggestion for condition in failed) - + "." - ) - primary_code = first.reason_code - else: - rule_name = "allowed" - narrative = ( - f"Request for '{cid}' by '{pid}' would be allowed by " - "DefaultPolicyEngine." - ) - primary_code = None - - return DenialExplanation( - denied=denied, - rule_name=rule_name, - failed_conditions=failed, - remediation=remediation, - narrative=narrative, - reason_code=primary_code, - ) From 617f36aa3e536475f1fde9bfccce9de9036e11e0 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:01:17 +0100 Subject: [PATCH 12/25] Split shared policy rule state below module budget --- .../default_policy_rule_types.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/weaver_kernel/default_policy_rule_types.py diff --git a/src/weaver_kernel/default_policy_rule_types.py b/src/weaver_kernel/default_policy_rule_types.py new file mode 100644 index 0000000..89aa4de --- /dev/null +++ b/src/weaver_kernel/default_policy_rule_types.py @@ -0,0 +1,64 @@ +"""Internal data structures shared by the default-policy rule modules.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .enums import SafetyClass +from .models import ( + Capability, + CapabilityRequest, + FailedCondition, + PolicyTraceStep, + Principal, +) +from .rate_limit import RateLimiter + +MIN_JUSTIFICATION = 15 +MAX_ROWS_USER = 50 +MAX_ROWS_SERVICE = 500 + + +@dataclass(slots=True) +class RuleFailure: + """One failed default-policy condition and its decision/explanation views.""" + + detail: str + condition: FailedCondition + reason_code: str + cause: Exception | None = None + + +@dataclass(slots=True) +class RuleContext: + """Mutable traversal context shared by the ordered rule checks.""" + + request: CapabilityRequest + capability: Capability + principal: Principal + justification: str + constraints: dict[str, Any] + rate_limits: dict[SafetyClass, tuple[int, float]] + limiter: RateLimiter + read_only: bool + trace_steps: list[PolicyTraceStep] = field(default_factory=list) + + +@dataclass(slots=True) +class RuleChainResult: + """Result of traversing the ordered default-policy rule chain.""" + + constraints: dict[str, Any] + failures: list[RuleFailure] = field(default_factory=list) + trace_steps: list[PolicyTraceStep] = field(default_factory=list) + + +__all__ = [ + "MAX_ROWS_SERVICE", + "MAX_ROWS_USER", + "MIN_JUSTIFICATION", + "RuleChainResult", + "RuleContext", + "RuleFailure", +] From eea907ace55953605c19c5d604be6d39008af1f5 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:01:41 +0100 Subject: [PATCH 13/25] Split access policy checks below module budget --- .../default_policy_access_rules.py | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 src/weaver_kernel/default_policy_access_rules.py diff --git a/src/weaver_kernel/default_policy_access_rules.py b/src/weaver_kernel/default_policy_access_rules.py new file mode 100644 index 0000000..511968e --- /dev/null +++ b/src/weaver_kernel/default_policy_access_rules.py @@ -0,0 +1,216 @@ +"""Access, sensitivity, and memory checks for the default policy chain.""" + +from __future__ import annotations + +from .default_policy_rule_types import MIN_JUSTIFICATION, RuleContext, RuleFailure +from .enums import SafetyClass, SensitivityTag +from .models import FailedCondition, PolicyTraceStep +from .policy_reasons import DenialReason + + +def _justification_failure(ctx: RuleContext, label: str) -> RuleFailure | None: + stripped_len = len(ctx.justification.strip()) + if stripped_len >= MIN_JUSTIFICATION: + return None + detail = ( + f"{label} capabilities require a justification of at least " + f"{MIN_JUSTIFICATION} characters. Got {len(ctx.justification)} characters " + f"({stripped_len} after trimming whitespace)." + ) + return RuleFailure( + detail=detail, + condition=FailedCondition( + condition="min_justification", + required=MIN_JUSTIFICATION, + actual=stripped_len, + suggestion=( + f"Provide justification with at least {MIN_JUSTIFICATION} " + f"characters (currently {stripped_len})" + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ), + reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), + ) + + +def check_safety_class(ctx: RuleContext) -> list[RuleFailure]: + """Apply WRITE/DESTRUCTIVE role and justification requirements.""" + failures: list[RuleFailure] = [] + roles = set(ctx.principal.roles) + pid = ctx.principal.principal_id + + if ctx.capability.safety_class == SafetyClass.WRITE: + if not (roles & {"writer", "admin"}): + failures.append( + RuleFailure( + detail=( + "WRITE capabilities require the 'writer' or 'admin' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["writer", "admin"], + actual=sorted(roles), + suggestion=f"Add 'writer' or 'admin' role to principal '{pid}'", + reason_code=str(DenialReason.MISSING_ROLE), + ), + reason_code=str(DenialReason.MISSING_ROLE), + ) + ) + failure = _justification_failure(ctx, "WRITE") + if failure is not None: + failures.append(failure) + + elif ctx.capability.safety_class == SafetyClass.DESTRUCTIVE: + if "admin" not in roles: + failures.append( + RuleFailure( + detail=( + "DESTRUCTIVE capabilities require the 'admin' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["admin"], + actual=sorted(roles), + suggestion=f"Add 'admin' role to principal '{pid}'", + reason_code=str(DenialReason.MISSING_ROLE), + ), + reason_code=str(DenialReason.MISSING_ROLE), + ) + ) + failure = _justification_failure(ctx, "DESTRUCTIVE") + if failure is not None: + failures.append(failure) + + return failures + + +def check_tenant_sensitivity(ctx: RuleContext) -> list[RuleFailure]: + """Apply PII/PCI tenant requirements and allowed-field narrowing.""" + if ctx.capability.sensitivity not in (SensitivityTag.PII, SensitivityTag.PCI): + return [] + pid = ctx.principal.principal_id + if "tenant" not in ctx.principal.attributes: + return [ + RuleFailure( + detail=( + f"Capability '{ctx.capability.capability_id}' has " + f"{ctx.capability.sensitivity.value} sensitivity and requires " + "the principal to have a 'tenant' attribute." + ), + condition=FailedCondition( + condition="tenant_attribute", + required="present", + actual="absent", + suggestion=f"Add 'tenant' attribute to principal '{pid}'", + reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), + ), + reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), + ) + ] + + roles = set(ctx.principal.roles) + if ctx.capability.allowed_fields and "pii_reader" not in roles: + ctx.constraints["allowed_fields"] = ctx.capability.allowed_fields + ctx.trace_steps.append( + PolicyTraceStep( + name="sensitivity:allowed_fields", + outcome="constraint_applied", + detail=f"applied allowed_fields={ctx.capability.allowed_fields}", + ) + ) + return [] + + +def check_secrets(ctx: RuleContext) -> list[RuleFailure]: + """Apply SECRETS role and justification requirements.""" + if ctx.capability.sensitivity != SensitivityTag.SECRETS: + return [] + failures: list[RuleFailure] = [] + roles = set(ctx.principal.roles) + pid = ctx.principal.principal_id + if not (roles & {"admin", "secrets_reader"}): + failures.append( + RuleFailure( + detail=( + "SECRETS capabilities require the 'admin' or 'secrets_reader' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["admin", "secrets_reader"], + actual=sorted(roles), + suggestion=f"Add 'admin' or 'secrets_reader' role to principal '{pid}'", + reason_code=str(DenialReason.MISSING_ROLE), + ), + reason_code=str(DenialReason.MISSING_ROLE), + ) + ) + failure = _justification_failure(ctx, "SECRETS") + if failure is not None: + failures.append(failure) + return failures + + +def check_memory(ctx: RuleContext) -> list[RuleFailure]: + """Apply MEMORY write and sensitive-read role requirements.""" + if ctx.capability.sensitivity != SensitivityTag.MEMORY: + return [] + roles = set(ctx.principal.roles) + pid = ctx.principal.principal_id + memory_scope = str(ctx.request.scope.get("memory_scope", "")) if ctx.request.scope else "" + is_write = ctx.capability.safety_class in (SafetyClass.WRITE, SafetyClass.DESTRUCTIVE) + + if is_write and not (roles & {"memory_writer", "admin"}): + return [ + RuleFailure( + detail=( + "MEMORY write capabilities require the 'memory_writer' or 'admin' role. " + f"Principal '{pid}' has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["memory_writer", "admin"], + actual=sorted(roles), + suggestion=f"Add 'memory_writer' or 'admin' role to principal '{pid}'", + reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), + ), + reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), + ) + ] + + if ( + not is_write + and memory_scope == "sensitive" + and not (roles & {"memory_reader_sensitive", "admin"}) + ): + return [ + RuleFailure( + detail=( + "MEMORY read with scope='sensitive' requires the " + f"'memory_reader_sensitive' or 'admin' role. Principal '{pid}' " + f"has roles: {sorted(roles)}." + ), + condition=FailedCondition( + condition="roles", + required=["memory_reader_sensitive", "admin"], + actual=sorted(roles), + suggestion=( + f"Add 'memory_reader_sensitive' or 'admin' role to principal '{pid}' " + "(or narrow the request scope away from 'sensitive')" + ), + reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), + ), + reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), + ) + ] + return [] + + +__all__ = [ + "check_memory", + "check_safety_class", + "check_secrets", + "check_tenant_sensitivity", +] From 405677ad557d2015add68444140fd823589a6aad Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:01:55 +0100 Subject: [PATCH 14/25] Split limit policy checks below module budget --- .../default_policy_limit_rules.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/weaver_kernel/default_policy_limit_rules.py diff --git a/src/weaver_kernel/default_policy_limit_rules.py b/src/weaver_kernel/default_policy_limit_rules.py new file mode 100644 index 0000000..f41ce2c --- /dev/null +++ b/src/weaver_kernel/default_policy_limit_rules.py @@ -0,0 +1,98 @@ +"""Constraint and rate-limit checks for the default policy chain.""" + +from __future__ import annotations + +from .default_policy_rule_types import ( + MAX_ROWS_SERVICE, + MAX_ROWS_USER, + RuleContext, + RuleFailure, +) +from .models import FailedCondition, PolicyTraceStep +from .policy_reasons import DenialReason +from .rate_limit import SERVICE_RATE_MULTIPLIER + + +def apply_row_cap(ctx: RuleContext) -> list[RuleFailure]: + """Validate/cap ``max_rows`` and record the applied constraint.""" + roles = set(ctx.principal.roles) + max_rows = MAX_ROWS_SERVICE if "service" in roles else MAX_ROWS_USER + if "max_rows" in ctx.constraints: + try: + requested = int(ctx.constraints["max_rows"]) + except (TypeError, ValueError) as exc: + return [ + RuleFailure( + detail=( + f"Invalid 'max_rows' constraint: {ctx.constraints['max_rows']!r} " + "is not a valid integer." + ), + condition=FailedCondition( + condition="max_rows", + required="integer", + actual=ctx.constraints["max_rows"], + suggestion="Provide 'max_rows' as a valid integer", + reason_code=str(DenialReason.INVALID_CONSTRAINT), + ), + reason_code=str(DenialReason.INVALID_CONSTRAINT), + cause=exc, + ) + ] + ctx.constraints["max_rows"] = min(max(requested, 0), max_rows) + else: + ctx.constraints["max_rows"] = max_rows + + ctx.trace_steps.append( + PolicyTraceStep( + name="row_cap", + outcome="constraint_applied", + detail="max_rows capped", + ) + ) + return [] + + +def check_rate_limit(ctx: RuleContext) -> list[RuleFailure]: + """Check the current sliding window and record usage only for decisions.""" + safety_class = ctx.capability.safety_class + if safety_class not in ctx.rate_limits: + return [] + + roles = set(ctx.principal.roles) + limit, window = ctx.rate_limits[safety_class] + if "service" in roles: + limit *= SERVICE_RATE_MULTIPLIER + pid = ctx.principal.principal_id + cid = ctx.capability.capability_id + rate_key = f"{pid}:{cid}" + allowed = ( + ctx.limiter.peek(rate_key, limit, window) + if ctx.read_only + else ctx.limiter.check(rate_key, limit, window) + ) + if not allowed: + return [ + RuleFailure( + detail=( + f"Rate limit exceeded: {limit} {safety_class.value} " + f"invocations per {window}s for principal '{pid}'" + ), + condition=FailedCondition( + condition="rate_limit", + required=f"fewer than {limit} invocations per {window}s", + actual="limit exceeded", + suggestion=( + f"Wait for the {window}s rate-limit window before retrying " + f"capability '{cid}'" + ), + reason_code=str(DenialReason.RATE_LIMITED), + ), + reason_code=str(DenialReason.RATE_LIMITED), + ) + ] + if not ctx.read_only: + ctx.limiter.record(rate_key) + return [] + + +__all__ = ["apply_row_cap", "check_rate_limit"] From 43c5e2649eba9d329b1fc563f2d9f379d99f7839 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:02:08 +0100 Subject: [PATCH 15/25] Keep one ordered rule chain below module budget --- src/weaver_kernel/default_policy_rules.py | 416 ++++------------------ 1 file changed, 64 insertions(+), 352 deletions(-) diff --git a/src/weaver_kernel/default_policy_rules.py b/src/weaver_kernel/default_policy_rules.py index e0cbd74..afbbf6f 100644 --- a/src/weaver_kernel/default_policy_rules.py +++ b/src/weaver_kernel/default_policy_rules.py @@ -1,49 +1,41 @@ -"""Shared rule chain for :class:`~weaver_kernel.policy.DefaultPolicyEngine`. - -This module owns the ordered default-policy conditions. ``evaluate()`` and -``explain()`` deliberately traverse this same chain with different modes: -short-circuit + stateful rate limiting for decisions, collect-all + read-only -rate inspection for explanations. -""" +"""Ordered rule chain shared by default policy decisions and explanations.""" from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any +from collections.abc import Callable -from .enums import SafetyClass, SensitivityTag -from .models import ( - Capability, - CapabilityRequest, - FailedCondition, - PolicyTraceStep, - Principal, +from .default_policy_access_rules import ( + check_memory, + check_safety_class, + check_secrets, + check_tenant_sensitivity, +) +from .default_policy_limit_rules import apply_row_cap, check_rate_limit +from .default_policy_rule_types import ( + MAX_ROWS_SERVICE, + MAX_ROWS_USER, + MIN_JUSTIFICATION, + RuleChainResult, + RuleContext, + RuleFailure, +) +from .enums import SafetyClass +from .models import Capability, CapabilityRequest, Principal +from .rate_limit import RateLimiter + +RuleCheck = Callable[[RuleContext], list[RuleFailure]] + +# Canonical order is defined once here. Both evaluate() and explain() traverse +# exactly this sequence; their only differences are short-circuit/collect-all +# and stateful/read-only rate-limit modes. +_DEFAULT_RULES: tuple[RuleCheck, ...] = ( + check_safety_class, + check_tenant_sensitivity, + check_secrets, + check_memory, + apply_row_cap, + check_rate_limit, ) -from .policy_reasons import DenialReason -from .rate_limit import SERVICE_RATE_MULTIPLIER, RateLimiter - -MIN_JUSTIFICATION = 15 -MAX_ROWS_USER = 50 -MAX_ROWS_SERVICE = 500 - - -@dataclass(slots=True) -class RuleFailure: - """One failed default-policy condition and its decision/explanation views.""" - - detail: str - condition: FailedCondition - reason_code: str - cause: Exception | None = None - - -@dataclass(slots=True) -class RuleChainResult: - """Result of traversing the ordered default-policy rule chain.""" - - constraints: dict[str, Any] - failures: list[RuleFailure] = field(default_factory=list) - trace_steps: list[PolicyTraceStep] = field(default_factory=list) class DefaultPolicyRuleChain: @@ -68,326 +60,46 @@ def run( collect_all: bool, read_only: bool, ) -> RuleChainResult: - """Traverse the rules once in canonical order. + """Traverse the canonical rules in decision or explanation mode. Args: request: Capability request being checked. capability: Target capability. principal: Requesting principal. justification: Caller-supplied justification. - collect_all: Collect every failure instead of stopping at the first. - read_only: Do not mutate transient policy state such as rate windows. + collect_all: Collect every failed condition instead of stopping at + the first one. + read_only: Avoid policy-state mutation, including rate-window + creation, pruning, and usage recording. Returns: - Constraints, failed conditions, and non-terminal trace steps. + Constraints, failures, and non-terminal trace steps produced by the + common rule traversal. """ - roles = set(principal.roles) - constraints: dict[str, Any] = dict(request.constraints) - result = RuleChainResult(constraints=constraints) - pid = principal.principal_id - cid = capability.capability_id - - def add_failure( - *, - detail: str, - condition: FailedCondition, - reason_code: str, - cause: Exception | None = None, - ) -> bool: - result.failures.append( - RuleFailure( - detail=detail, - condition=condition, - reason_code=reason_code, - cause=cause, - ) - ) - return not collect_all - - # ── Safety class checks ────────────────────────────────────────── - if capability.safety_class == SafetyClass.WRITE: - if not (roles & {"writer", "admin"}): - detail = ( - f"WRITE capabilities require the 'writer' or 'admin' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="roles", - required=["writer", "admin"], - actual=sorted(roles), - suggestion=f"Add 'writer' or 'admin' role to principal '{pid}'", - reason_code=str(DenialReason.MISSING_ROLE), - ), - reason_code=str(DenialReason.MISSING_ROLE), - ): - return result - stripped_len = len(justification.strip()) - if stripped_len < MIN_JUSTIFICATION: - detail = ( - f"WRITE capabilities require a justification of at least " - f"{MIN_JUSTIFICATION} characters. " - f"Got {len(justification)} characters " - f"({stripped_len} after trimming whitespace)." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="min_justification", - required=MIN_JUSTIFICATION, - actual=stripped_len, - suggestion=( - f"Provide justification with at least {MIN_JUSTIFICATION} " - f"characters (currently {stripped_len})" - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ): - return result - - elif capability.safety_class == SafetyClass.DESTRUCTIVE: - if "admin" not in roles: - detail = ( - f"DESTRUCTIVE capabilities require the 'admin' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="roles", - required=["admin"], - actual=sorted(roles), - suggestion=f"Add 'admin' role to principal '{pid}'", - reason_code=str(DenialReason.MISSING_ROLE), - ), - reason_code=str(DenialReason.MISSING_ROLE), - ): - return result - stripped_len = len(justification.strip()) - if stripped_len < MIN_JUSTIFICATION: - detail = ( - f"DESTRUCTIVE capabilities require a justification of at least " - f"{MIN_JUSTIFICATION} characters. " - f"Got {len(justification)} characters " - f"({stripped_len} after trimming whitespace)." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="min_justification", - required=MIN_JUSTIFICATION, - actual=stripped_len, - suggestion=( - f"Provide justification with at least {MIN_JUSTIFICATION} " - f"characters (currently {stripped_len})" - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ): - return result - - # ── Sensitivity checks ─────────────────────────────────────────── - if capability.sensitivity in (SensitivityTag.PII, SensitivityTag.PCI): - if "tenant" not in principal.attributes: - detail = ( - f"Capability '{cid}' has " - f"{capability.sensitivity.value} sensitivity and requires " - "the principal to have a 'tenant' attribute." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="tenant_attribute", - required="present", - actual="absent", - suggestion=f"Add 'tenant' attribute to principal '{pid}'", - reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), - ), - reason_code=str(DenialReason.MISSING_TENANT_ATTRIBUTE), - ): - return result - if capability.allowed_fields and "pii_reader" not in roles: - constraints["allowed_fields"] = capability.allowed_fields - result.trace_steps.append( - PolicyTraceStep( - name="sensitivity:allowed_fields", - outcome="constraint_applied", - detail=f"applied allowed_fields={capability.allowed_fields}", - ) - ) - - if capability.sensitivity == SensitivityTag.SECRETS: - if not (roles & {"admin", "secrets_reader"}): - detail = ( - f"SECRETS capabilities require the 'admin' or 'secrets_reader' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="roles", - required=["admin", "secrets_reader"], - actual=sorted(roles), - suggestion=f"Add 'admin' or 'secrets_reader' role to principal '{pid}'", - reason_code=str(DenialReason.MISSING_ROLE), - ), - reason_code=str(DenialReason.MISSING_ROLE), - ): - return result - stripped_len = len(justification.strip()) - if stripped_len < MIN_JUSTIFICATION: - detail = ( - f"SECRETS capabilities require a justification of at least " - f"{MIN_JUSTIFICATION} characters. " - f"Got {len(justification)} characters " - f"({stripped_len} after trimming whitespace)." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="min_justification", - required=MIN_JUSTIFICATION, - actual=stripped_len, - suggestion=( - f"Provide justification with at least {MIN_JUSTIFICATION} " - f"characters (currently {stripped_len})" - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ), - reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), - ): - return result - - # ── Memory checks ──────────────────────────────────────────────── - if capability.sensitivity == SensitivityTag.MEMORY: - memory_scope = str(request.scope.get("memory_scope", "")) if request.scope else "" - is_write = capability.safety_class in ( - SafetyClass.WRITE, - SafetyClass.DESTRUCTIVE, - ) - if is_write and not (roles & {"memory_writer", "admin"}): - detail = ( - f"MEMORY write capabilities require the 'memory_writer' or " - f"'admin' role. Principal '{pid}' has roles: {sorted(roles)}." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="roles", - required=["memory_writer", "admin"], - actual=sorted(roles), - suggestion=f"Add 'memory_writer' or 'admin' role to principal '{pid}'", - reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), - ), - reason_code=str(DenialReason.MEMORY_WRITE_REQUIRES_WRITER), - ): - return result - if ( - not is_write - and memory_scope == "sensitive" - and not (roles & {"memory_reader_sensitive", "admin"}) - ): - detail = ( - f"MEMORY read with scope='sensitive' requires the " - f"'memory_reader_sensitive' or 'admin' role. " - f"Principal '{pid}' has roles: {sorted(roles)}." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="roles", - required=["memory_reader_sensitive", "admin"], - actual=sorted(roles), - suggestion=( - f"Add 'memory_reader_sensitive' or 'admin' role to " - f"principal '{pid}' (or narrow the request scope away " - f"from 'sensitive')" - ), - reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), - ), - reason_code=str(DenialReason.MEMORY_SENSITIVE_READ_DENIED), - ): - return result - - # ── Row cap ────────────────────────────────────────────────────── - max_rows = MAX_ROWS_SERVICE if "service" in roles else MAX_ROWS_USER - if "max_rows" in constraints: - try: - requested = int(constraints["max_rows"]) - except (TypeError, ValueError) as exc: - detail = ( - f"Invalid 'max_rows' constraint: {constraints['max_rows']!r} " - "is not a valid integer." - ) - if add_failure( - detail=detail, - condition=FailedCondition( - condition="max_rows", - required="integer", - actual=constraints["max_rows"], - suggestion="Provide 'max_rows' as a valid integer", - reason_code=str(DenialReason.INVALID_CONSTRAINT), - ), - reason_code=str(DenialReason.INVALID_CONSTRAINT), - cause=exc, - ): - return result - else: - constraints["max_rows"] = min(max(requested, 0), max_rows) - result.trace_steps.append( - PolicyTraceStep( - name="row_cap", - outcome="constraint_applied", - detail="max_rows capped", - ) - ) - else: - constraints["max_rows"] = max_rows - result.trace_steps.append( - PolicyTraceStep( - name="row_cap", - outcome="constraint_applied", - detail="max_rows capped", - ) - ) - - # ── Rate limiting ──────────────────────────────────────────────── - rate_key = f"{pid}:{cid}" - if capability.safety_class in self._rate_limits: - limit, window = self._rate_limits[capability.safety_class] - if "service" in roles: - limit *= SERVICE_RATE_MULTIPLIER - allowed = ( - self._limiter.peek(rate_key, limit, window) - if read_only - else self._limiter.check(rate_key, limit, window) - ) - if not allowed: - detail = ( - f"Rate limit exceeded: {limit} {capability.safety_class.value} " - f"invocations per {window}s for principal '{pid}'" - ) - add_failure( - detail=detail, - condition=FailedCondition( - condition="rate_limit", - required=f"fewer than {limit} invocations per {window}s", - actual="limit exceeded", - suggestion=( - f"Wait for the {window}s rate-limit window before retrying " - f"capability '{cid}'" - ), - reason_code=str(DenialReason.RATE_LIMITED), - ), - reason_code=str(DenialReason.RATE_LIMITED), - ) - elif not read_only: - self._limiter.record(rate_key) - - return result + ctx = RuleContext( + request=request, + capability=capability, + principal=principal, + justification=justification, + constraints=dict(request.constraints), + rate_limits=self._rate_limits, + limiter=self._limiter, + read_only=read_only, + ) + failures: list[RuleFailure] = [] + for rule in _DEFAULT_RULES: + rule_failures = rule(ctx) + if rule_failures: + failures.extend(rule_failures) + if not collect_all: + failures = failures[:1] + break + + return RuleChainResult( + constraints=ctx.constraints, + failures=failures, + trace_steps=ctx.trace_steps, + ) __all__ = [ From 530c3162719c8c35628e345b2a92e3fd310d626d Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:02:42 +0100 Subject: [PATCH 16/25] Add final architecture verification for #219 --- .github/workflows/agent-final-219.yml | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/agent-final-219.yml diff --git a/.github/workflows/agent-final-219.yml b/.github/workflows/agent-final-219.yml new file mode 100644 index 0000000..b174988 --- /dev/null +++ b/.github/workflows/agent-final-219.yml @@ -0,0 +1,48 @@ +name: Temporary final architecture verification for #219 + +on: + push: + branches: + - agent/unify-policy-rule-chain-219 + +permissions: + contents: write + +jobs: + verify: + if: ${{ !contains(github.event.head_commit.message, '[agent-final-219]') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: agent/unify-policy-rule-chain-219 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.12' + - name: Update canonical agent guidance + run: | + python - <<'PY' + from pathlib import Path + + path = Path("AGENTS.md") + text = path.read_text(encoding="utf-8") + old = """## Adding a policy rule\n\n1. Add the rule to `DefaultPolicyEngine.evaluate()` in `policy.py`.\n2. **Placement matters:** rules are evaluated in order. A new rule placed before sensitivity checks silently bypasses them.\n3. If adding a new `SensitivityTag`, you must also add a corresponding policy rule — otherwise the tag is silently ignored.\n4. Cover it with a test in `tests/test_policy.py`.\n""" + new = """## Adding a policy rule\n\n1. Implement the condition in the appropriate `default_policy_*_rules.py` helper.\n2. Register it exactly once in the ordered `_DEFAULT_RULES` tuple in `default_policy_rules.py`; both `evaluate()` and `explain()` consume that chain.\n3. **Placement matters:** rules are evaluated in order. A new rule placed before sensitivity checks can change which denial short-circuits first.\n4. If adding a new `SensitivityTag`, you must also add a corresponding policy rule — otherwise the tag is silently ignored.\n5. Cover decision/explanation agreement in `tests/test_policy_rule_chain.py` and behavior in `tests/test_policy.py`.\n""" + if text.count(old) != 1: + raise SystemExit(f"expected one policy-guidance block, got {text.count(old)}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + PY + git diff --check + - name: Install development dependencies + run: python -m pip install -e '.[dev]' + - name: Run authoritative repository gate + run: make ci + - name: Commit verified guidance + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add AGENTS.md + if ! git diff --cached --quiet; then + git commit -m "[agent-final-219] Point policy guidance at shared rule chain" + git push origin HEAD:agent/unify-policy-rule-chain-219 + fi From 727f97f644b9a8dcac820b5db66afdba326f5e4b Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:03:27 +0100 Subject: [PATCH 17/25] Format and validate final #219 architecture --- .github/workflows/agent-final-219.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/agent-final-219.yml b/.github/workflows/agent-final-219.yml index b174988..e068945 100644 --- a/.github/workflows/agent-final-219.yml +++ b/.github/workflows/agent-final-219.yml @@ -35,14 +35,16 @@ jobs: git diff --check - name: Install development dependencies run: python -m pip install -e '.[dev]' + - name: Normalize formatting + run: python -m ruff format src/ tests/ examples/ - name: Run authoritative repository gate run: make ci - - name: Commit verified guidance + - name: Commit verified final state run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add AGENTS.md + git add AGENTS.md src/ tests/ if ! git diff --cached --quiet; then - git commit -m "[agent-final-219] Point policy guidance at shared rule chain" + git commit -m "[agent-final-219] Validate shared policy rule chain" git push origin HEAD:agent/unify-policy-rule-chain-219 fi From dbb87743326a60908d514ab206cb577402c0b2af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:04:28 +0000 Subject: [PATCH 18/25] [agent-final-219] Validate shared policy rule chain --- AGENTS.md | 9 +++++---- src/weaver_kernel/policy.py | 8 ++------ tests/test_policy_rule_chain.py | 4 +++- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fdb4caf..167800f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,10 +136,11 @@ See [docs/integrations.md](docs/integrations.md) for MCP and HTTP examples. ## Adding a policy rule -1. Add the rule to `DefaultPolicyEngine.evaluate()` in `policy.py`. -2. **Placement matters:** rules are evaluated in order. A new rule placed before sensitivity checks silently bypasses them. -3. If adding a new `SensitivityTag`, you must also add a corresponding policy rule — otherwise the tag is silently ignored. -4. Cover it with a test in `tests/test_policy.py`. +1. Implement the condition in the appropriate `default_policy_*_rules.py` helper. +2. Register it exactly once in the ordered `_DEFAULT_RULES` tuple in `default_policy_rules.py`; both `evaluate()` and `explain()` consume that chain. +3. **Placement matters:** rules are evaluated in order. A new rule placed before sensitivity checks can change which denial short-circuits first. +4. If adding a new `SensitivityTag`, you must also add a corresponding policy rule — otherwise the tag is silently ignored. +5. Cover decision/explanation agreement in `tests/test_policy_rule_chain.py` and behavior in `tests/test_policy.py`. ## Review checklist (beyond `make ci`) diff --git a/src/weaver_kernel/policy.py b/src/weaver_kernel/policy.py index 8a708a4..037b1ca 100644 --- a/src/weaver_kernel/policy.py +++ b/src/weaver_kernel/policy.py @@ -299,8 +299,7 @@ def explain( if denied: first = failed[0] rule_name = ( - f"{capability.safety_class.value.lower()}-" - f"{first.condition.replace('_', '-')}" + f"{capability.safety_class.value.lower()}-{first.condition.replace('_', '-')}" ) narrative = ( f"Request for '{cid}' by '{pid}' would be denied: " @@ -310,10 +309,7 @@ def explain( primary_code = first.reason_code else: rule_name = "allowed" - narrative = ( - f"Request for '{cid}' by '{pid}' would be allowed by " - "DefaultPolicyEngine." - ) + narrative = f"Request for '{cid}' by '{pid}' would be allowed by DefaultPolicyEngine." primary_code = None return DenialExplanation( diff --git a/tests/test_policy_rule_chain.py b/tests/test_policy_rule_chain.py index a1a1cf8..b8c08e7 100644 --- a/tests/test_policy_rule_chain.py +++ b/tests/test_policy_rule_chain.py @@ -34,7 +34,9 @@ def _cap( ) -def _request(*, max_rows: object | None = None, memory_scope: str | None = None) -> CapabilityRequest: +def _request( + *, max_rows: object | None = None, memory_scope: str | None = None +) -> CapabilityRequest: constraints = {} if max_rows is None else {"max_rows": max_rows} scope = {} if memory_scope is None else {"memory_scope": memory_scope} return CapabilityRequest( From 84ee48b7e22e4c6f36e2a8a77e13c11350b73a6c Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:05:13 +0100 Subject: [PATCH 19/25] Remove temporary final #219 verification workflow --- .github/workflows/agent-final-219.yml | 50 --------------------------- 1 file changed, 50 deletions(-) delete mode 100644 .github/workflows/agent-final-219.yml diff --git a/.github/workflows/agent-final-219.yml b/.github/workflows/agent-final-219.yml deleted file mode 100644 index e068945..0000000 --- a/.github/workflows/agent-final-219.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Temporary final architecture verification for #219 - -on: - push: - branches: - - agent/unify-policy-rule-chain-219 - -permissions: - contents: write - -jobs: - verify: - if: ${{ !contains(github.event.head_commit.message, '[agent-final-219]') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: agent/unify-policy-rule-chain-219 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: '3.12' - - name: Update canonical agent guidance - run: | - python - <<'PY' - from pathlib import Path - - path = Path("AGENTS.md") - text = path.read_text(encoding="utf-8") - old = """## Adding a policy rule\n\n1. Add the rule to `DefaultPolicyEngine.evaluate()` in `policy.py`.\n2. **Placement matters:** rules are evaluated in order. A new rule placed before sensitivity checks silently bypasses them.\n3. If adding a new `SensitivityTag`, you must also add a corresponding policy rule — otherwise the tag is silently ignored.\n4. Cover it with a test in `tests/test_policy.py`.\n""" - new = """## Adding a policy rule\n\n1. Implement the condition in the appropriate `default_policy_*_rules.py` helper.\n2. Register it exactly once in the ordered `_DEFAULT_RULES` tuple in `default_policy_rules.py`; both `evaluate()` and `explain()` consume that chain.\n3. **Placement matters:** rules are evaluated in order. A new rule placed before sensitivity checks can change which denial short-circuits first.\n4. If adding a new `SensitivityTag`, you must also add a corresponding policy rule — otherwise the tag is silently ignored.\n5. Cover decision/explanation agreement in `tests/test_policy_rule_chain.py` and behavior in `tests/test_policy.py`.\n""" - if text.count(old) != 1: - raise SystemExit(f"expected one policy-guidance block, got {text.count(old)}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - PY - git diff --check - - name: Install development dependencies - run: python -m pip install -e '.[dev]' - - name: Normalize formatting - run: python -m ruff format src/ tests/ examples/ - - name: Run authoritative repository gate - run: make ci - - name: Commit verified final state - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add AGENTS.md src/ tests/ - if ! git diff --cached --quiet; then - git commit -m "[agent-final-219] Validate shared policy rule chain" - git push origin HEAD:agent/unify-policy-rule-chain-219 - fi From 807d629edd4ebc04f5c6a46abd78cedea18e7b30 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:07:40 +0100 Subject: [PATCH 20/25] Add temporary CodeQL cleanup verifier for #219 --- .github/workflows/agent-cleanup-219.yml | 51 +++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/agent-cleanup-219.yml diff --git a/.github/workflows/agent-cleanup-219.yml b/.github/workflows/agent-cleanup-219.yml new file mode 100644 index 0000000..0aae3ba --- /dev/null +++ b/.github/workflows/agent-cleanup-219.yml @@ -0,0 +1,51 @@ +name: Temporary CodeQL cleanup verifier for #219 + +on: + push: + branches: + - agent/unify-policy-rule-chain-219 + +permissions: + contents: write + +jobs: + cleanup: + if: ${{ !contains(github.event.head_commit.message, '[agent-cleanup-219]') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: agent/unify-policy-rule-chain-219 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.12' + - name: Remove dead compatibility aliases + run: | + python - <<'PY' + from pathlib import Path + path = Path("src/weaver_kernel/policy.py") + text = path.read_text(encoding="utf-8") + old_import = '''from .default_policy_rules import (\n MAX_ROWS_SERVICE,\n MAX_ROWS_USER,\n MIN_JUSTIFICATION,\n DefaultPolicyRuleChain,\n)\n''' + new_import = 'from .default_policy_rules import DefaultPolicyRuleChain\n' + if text.count(old_import) != 1: + raise SystemExit(f"unexpected default_policy_rules import count: {text.count(old_import)}") + text = text.replace(old_import, new_import, 1) + dead = '''# Minimum justification length for WRITE operations.\n_MIN_JUSTIFICATION = MIN_JUSTIFICATION\n\n# Default max_rows caps.\n_MAX_ROWS_USER = MAX_ROWS_USER\n_MAX_ROWS_SERVICE = MAX_ROWS_SERVICE\n\n''' + if text.count(dead) != 1: + raise SystemExit(f"unexpected dead-alias block count: {text.count(dead)}") + path.write_text(text.replace(dead, "", 1), encoding="utf-8") + PY + git diff --check + - name: Install development dependencies + run: python -m pip install -e '.[dev]' + - name: Run authoritative repository gate + run: make ci + - name: Commit verified cleanup + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/weaver_kernel/policy.py + if ! git diff --cached --quiet; then + git commit -m "[agent-cleanup-219] Remove unused policy aliases" + git push origin HEAD:agent/unify-policy-rule-chain-219 + fi From 644a4cf3227a71978338e36cb83e03048a1648d9 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:09:15 +0100 Subject: [PATCH 21/25] Move coding-agent justification constant to canonical module --- .github/workflows/agent-cleanup-219.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/agent-cleanup-219.yml b/.github/workflows/agent-cleanup-219.yml index 0aae3ba..278cf93 100644 --- a/.github/workflows/agent-cleanup-219.yml +++ b/.github/workflows/agent-cleanup-219.yml @@ -23,6 +23,7 @@ jobs: run: | python - <<'PY' from pathlib import Path + path = Path("src/weaver_kernel/policy.py") text = path.read_text(encoding="utf-8") old_import = '''from .default_policy_rules import (\n MAX_ROWS_SERVICE,\n MAX_ROWS_USER,\n MIN_JUSTIFICATION,\n DefaultPolicyRuleChain,\n)\n''' @@ -34,6 +35,19 @@ jobs: if text.count(dead) != 1: raise SystemExit(f"unexpected dead-alias block count: {text.count(dead)}") path.write_text(text.replace(dead, "", 1), encoding="utf-8") + + path = Path("src/weaver_kernel/coding_agent.py") + text = path.read_text(encoding="utf-8") + old = 'from .policy import _MIN_JUSTIFICATION\n' + new = 'from .default_policy_rule_types import MIN_JUSTIFICATION\n' + if text.count(old) != 1: + raise SystemExit(f"unexpected coding_agent import count: {text.count(old)}") + text = text.replace(old, new, 1) + count = text.count("_MIN_JUSTIFICATION") + if count == 0: + raise SystemExit("expected coding_agent to use _MIN_JUSTIFICATION") + text = text.replace("_MIN_JUSTIFICATION", "MIN_JUSTIFICATION") + path.write_text(text, encoding="utf-8") PY git diff --check - name: Install development dependencies @@ -44,7 +58,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/weaver_kernel/policy.py + git add src/weaver_kernel/policy.py src/weaver_kernel/coding_agent.py if ! git diff --cached --quiet; then git commit -m "[agent-cleanup-219] Remove unused policy aliases" git push origin HEAD:agent/unify-policy-rule-chain-219 From 738bdc7fa8e162dc82a85faed523158ce00f704e Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:10:26 +0100 Subject: [PATCH 22/25] Let Ruff normalize cleanup import order --- .github/workflows/agent-cleanup-219.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/agent-cleanup-219.yml b/.github/workflows/agent-cleanup-219.yml index 278cf93..3468605 100644 --- a/.github/workflows/agent-cleanup-219.yml +++ b/.github/workflows/agent-cleanup-219.yml @@ -52,6 +52,8 @@ jobs: git diff --check - name: Install development dependencies run: python -m pip install -e '.[dev]' + - name: Normalize touched imports + run: python -m ruff check --fix src/weaver_kernel/policy.py src/weaver_kernel/coding_agent.py - name: Run authoritative repository gate run: make ci - name: Commit verified cleanup From 481d3e2460e260ba2b03c3b6321c02d3d9182dab Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:11:34 +0100 Subject: [PATCH 23/25] Move policy property tests to canonical constants --- .github/workflows/agent-cleanup-219.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agent-cleanup-219.yml b/.github/workflows/agent-cleanup-219.yml index 3468605..b189430 100644 --- a/.github/workflows/agent-cleanup-219.yml +++ b/.github/workflows/agent-cleanup-219.yml @@ -48,19 +48,35 @@ jobs: raise SystemExit("expected coding_agent to use _MIN_JUSTIFICATION") text = text.replace("_MIN_JUSTIFICATION", "MIN_JUSTIFICATION") path.write_text(text, encoding="utf-8") + + path = Path("tests/test_policy_properties.py") + text = path.read_text(encoding="utf-8") + old = 'from weaver_kernel.policy import _MAX_ROWS_SERVICE, _MAX_ROWS_USER\n' + new = ( + 'from weaver_kernel.default_policy_rule_types import (\n' + ' MAX_ROWS_SERVICE,\n' + ' MAX_ROWS_USER,\n' + ')\n' + ) + if text.count(old) != 1: + raise SystemExit(f"unexpected property-test import count: {text.count(old)}") + text = text.replace(old, new, 1) + text = text.replace("_MAX_ROWS_SERVICE", "MAX_ROWS_SERVICE") + text = text.replace("_MAX_ROWS_USER", "MAX_ROWS_USER") + path.write_text(text, encoding="utf-8") PY git diff --check - name: Install development dependencies run: python -m pip install -e '.[dev]' - name: Normalize touched imports - run: python -m ruff check --fix src/weaver_kernel/policy.py src/weaver_kernel/coding_agent.py + run: python -m ruff check --fix src/weaver_kernel/policy.py src/weaver_kernel/coding_agent.py tests/test_policy_properties.py - name: Run authoritative repository gate run: make ci - name: Commit verified cleanup run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/weaver_kernel/policy.py src/weaver_kernel/coding_agent.py + git add src/weaver_kernel/policy.py src/weaver_kernel/coding_agent.py tests/test_policy_properties.py if ! git diff --cached --quiet; then git commit -m "[agent-cleanup-219] Remove unused policy aliases" git push origin HEAD:agent/unify-policy-rule-chain-219 From 376b6abe522585ab1dbb3815bb9053736959d7f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:12:30 +0000 Subject: [PATCH 24/25] [agent-cleanup-219] Remove unused policy aliases --- src/weaver_kernel/coding_agent.py | 6 +++--- src/weaver_kernel/policy.py | 14 +------------- tests/test_policy_properties.py | 7 +++++-- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/weaver_kernel/coding_agent.py b/src/weaver_kernel/coding_agent.py index b9e424d..984126b 100644 --- a/src/weaver_kernel/coding_agent.py +++ b/src/weaver_kernel/coding_agent.py @@ -11,6 +11,7 @@ from dataclasses import dataclass from typing import Any, NoReturn +from .default_policy_rule_types import MIN_JUSTIFICATION from .enums import SafetyClass from .errors import DriverError, PolicyDenied from .models import ( @@ -21,7 +22,6 @@ PolicyTraceStep, Principal, ) -from .policy import _MIN_JUSTIFICATION from .policy_matching import scope_globs_match from .policy_reasons import AllowReason, DenialReason @@ -186,10 +186,10 @@ def _require_justification(cls, capability: Capability, justification: str) -> N if capability.safety_class not in (SafetyClass.WRITE, SafetyClass.DESTRUCTIVE): return stripped_len = len(justification.strip()) - if stripped_len < _MIN_JUSTIFICATION: + if stripped_len < MIN_JUSTIFICATION: cls._deny( f"{capability.safety_class.value.upper()} capabilities require a justification " - f"of at least {_MIN_JUSTIFICATION} characters after trimming whitespace.", + f"of at least {MIN_JUSTIFICATION} characters after trimming whitespace.", reason_code=str(DenialReason.INSUFFICIENT_JUSTIFICATION), ) diff --git a/src/weaver_kernel/policy.py b/src/weaver_kernel/policy.py index 037b1ca..1dd338c 100644 --- a/src/weaver_kernel/policy.py +++ b/src/weaver_kernel/policy.py @@ -6,12 +6,7 @@ from collections.abc import Callable from typing import Protocol -from .default_policy_rules import ( - MAX_ROWS_SERVICE, - MAX_ROWS_USER, - MIN_JUSTIFICATION, - DefaultPolicyRuleChain, -) +from .default_policy_rules import DefaultPolicyRuleChain from .enums import SafetyClass from .errors import AgentKernelError, PolicyDenied from .models import ( @@ -28,13 +23,6 @@ logger = logging.getLogger(__name__) -# Minimum justification length for WRITE operations. -_MIN_JUSTIFICATION = MIN_JUSTIFICATION - -# Default max_rows caps. -_MAX_ROWS_USER = MAX_ROWS_USER -_MAX_ROWS_SERVICE = MAX_ROWS_SERVICE - # Backwards-compatible aliases — these used to be defined here. New code # should import the names without the leading underscore from ``rate_limit``. _DEFAULT_RATE_LIMITS = DEFAULT_RATE_LIMITS diff --git a/tests/test_policy_properties.py b/tests/test_policy_properties.py index 63a26aa..51e6914 100644 --- a/tests/test_policy_properties.py +++ b/tests/test_policy_properties.py @@ -62,7 +62,10 @@ TokenScopeError, export_action_traces, ) -from weaver_kernel.policy import _MAX_ROWS_SERVICE, _MAX_ROWS_USER +from weaver_kernel.default_policy_rule_types import ( + MAX_ROWS_SERVICE, + MAX_ROWS_USER, +) # ── Shared strategies & helpers ───────────────────────────────────────────── @@ -207,7 +210,7 @@ def test_max_rows_never_exceeds_policy_cap( capability_id=capability.capability_id, goal="g", constraints=constraints ) decision = engine.evaluate(request, capability, principal, justification="") - cap_limit = _MAX_ROWS_SERVICE if "service" in principal.roles else _MAX_ROWS_USER + cap_limit = MAX_ROWS_SERVICE if "service" in principal.roles else MAX_ROWS_USER capped = decision.constraints["max_rows"] assert 0 <= capped <= cap_limit if requested_max_rows is not None and requested_max_rows >= 0: From c5b2cee50681150540897ad2909381e5544c76e9 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 14 Aug 2026 23:12:44 +0100 Subject: [PATCH 25/25] Remove temporary CodeQL cleanup verifier --- .github/workflows/agent-cleanup-219.yml | 83 ------------------------- 1 file changed, 83 deletions(-) delete mode 100644 .github/workflows/agent-cleanup-219.yml diff --git a/.github/workflows/agent-cleanup-219.yml b/.github/workflows/agent-cleanup-219.yml deleted file mode 100644 index b189430..0000000 --- a/.github/workflows/agent-cleanup-219.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Temporary CodeQL cleanup verifier for #219 - -on: - push: - branches: - - agent/unify-policy-rule-chain-219 - -permissions: - contents: write - -jobs: - cleanup: - if: ${{ !contains(github.event.head_commit.message, '[agent-cleanup-219]') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: agent/unify-policy-rule-chain-219 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: '3.12' - - name: Remove dead compatibility aliases - run: | - python - <<'PY' - from pathlib import Path - - path = Path("src/weaver_kernel/policy.py") - text = path.read_text(encoding="utf-8") - old_import = '''from .default_policy_rules import (\n MAX_ROWS_SERVICE,\n MAX_ROWS_USER,\n MIN_JUSTIFICATION,\n DefaultPolicyRuleChain,\n)\n''' - new_import = 'from .default_policy_rules import DefaultPolicyRuleChain\n' - if text.count(old_import) != 1: - raise SystemExit(f"unexpected default_policy_rules import count: {text.count(old_import)}") - text = text.replace(old_import, new_import, 1) - dead = '''# Minimum justification length for WRITE operations.\n_MIN_JUSTIFICATION = MIN_JUSTIFICATION\n\n# Default max_rows caps.\n_MAX_ROWS_USER = MAX_ROWS_USER\n_MAX_ROWS_SERVICE = MAX_ROWS_SERVICE\n\n''' - if text.count(dead) != 1: - raise SystemExit(f"unexpected dead-alias block count: {text.count(dead)}") - path.write_text(text.replace(dead, "", 1), encoding="utf-8") - - path = Path("src/weaver_kernel/coding_agent.py") - text = path.read_text(encoding="utf-8") - old = 'from .policy import _MIN_JUSTIFICATION\n' - new = 'from .default_policy_rule_types import MIN_JUSTIFICATION\n' - if text.count(old) != 1: - raise SystemExit(f"unexpected coding_agent import count: {text.count(old)}") - text = text.replace(old, new, 1) - count = text.count("_MIN_JUSTIFICATION") - if count == 0: - raise SystemExit("expected coding_agent to use _MIN_JUSTIFICATION") - text = text.replace("_MIN_JUSTIFICATION", "MIN_JUSTIFICATION") - path.write_text(text, encoding="utf-8") - - path = Path("tests/test_policy_properties.py") - text = path.read_text(encoding="utf-8") - old = 'from weaver_kernel.policy import _MAX_ROWS_SERVICE, _MAX_ROWS_USER\n' - new = ( - 'from weaver_kernel.default_policy_rule_types import (\n' - ' MAX_ROWS_SERVICE,\n' - ' MAX_ROWS_USER,\n' - ')\n' - ) - if text.count(old) != 1: - raise SystemExit(f"unexpected property-test import count: {text.count(old)}") - text = text.replace(old, new, 1) - text = text.replace("_MAX_ROWS_SERVICE", "MAX_ROWS_SERVICE") - text = text.replace("_MAX_ROWS_USER", "MAX_ROWS_USER") - path.write_text(text, encoding="utf-8") - PY - git diff --check - - name: Install development dependencies - run: python -m pip install -e '.[dev]' - - name: Normalize touched imports - run: python -m ruff check --fix src/weaver_kernel/policy.py src/weaver_kernel/coding_agent.py tests/test_policy_properties.py - - name: Run authoritative repository gate - run: make ci - - name: Commit verified cleanup - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/weaver_kernel/policy.py src/weaver_kernel/coding_agent.py tests/test_policy_properties.py - if ! git diff --cached --quiet; then - git commit -m "[agent-cleanup-219] Remove unused policy aliases" - git push origin HEAD:agent/unify-policy-rule-chain-219 - fi