diff --git a/aiac/CONTEXT.md b/aiac/CONTEXT.md index db111484..b2180fcc 100644 --- a/aiac/CONTEXT.md +++ b/aiac/CONTEXT.md @@ -30,6 +30,23 @@ The property that grants on a given scope come from exactly one place — the scope-focal pass. Door B adds only prohibitions and never grants. _Avoid_: owner, source of truth. +**Scope-exclusivity**: +Exclusive/restrictive language centred on a **scope** ("Only developers may read +source") — it closes that scope to a grantee role set. Owned by the +**scope-focal pass** (the subject gate): it grants the named roles and denies the +complement of candidate roles. Distinct from **role-exclusivity**. +_Avoid_: treating it as a Door B (role-focal) deny trigger. + +**Role-exclusivity**: +Exclusive/restrictive language centred on a user **role** ("Testers may access +**only** issues") — it closes that role to a scope set. Owned by **Door B** (the +user-role-focal pass): it denies the complement of candidate scopes. Conflating +it with **scope-exclusivity** — treating a scope-exclusive statement about a +*different* role as grounds for a Door B deny on the focal role — is the category +error #2511 corrects: that complement belongs to the scope-focal pass, so an +absent Door B prohibition is correct, not a defect. +_Avoid_: role pass exclusivity, subject exclusivity. + **Contradiction**: An *intra-pass* grant∩deny: one focal's own proposed rule set both grants and prohibits the same candidate. Detected by the LLM auditor within a single pass, diff --git a/aiac/src/aiac/agent/policy_rules_builder/prompts.py b/aiac/src/aiac/agent/policy_rules_builder/prompts.py index 2a3d7bab..b0ec56df 100644 --- a/aiac/src/aiac/agent/policy_rules_builder/prompts.py +++ b/aiac/src/aiac/agent/policy_rules_builder/prompts.py @@ -129,15 +129,27 @@ def _policy_block(policy_text: str) -> str: + _MAPPING_RULES + _DENY_RULES ) +# The MISSING-PROHIBITION guardrail below is AUDITOR-ONLY on purpose (#2511): it governs the +# auditor's REJECTION decision (when an absent focal deny is a defect vs. correct), not the grant/deny +# GENERATION the two sides share. The auditor was over-reaching — demanding a focal-role deny from a +# scope-exclusive statement that names a DIFFERENT role, then rejecting the proposer's (correct) empty +# proposal to exhaustion (-> 422). The proposer already applies rule 4 correctly, so the shared rules +# are left UNTOUCHED: putting this "empty is correct, don't reject" reassurance there regressed the +# proposer (it under-emitted legitimate description-driven denies). It belongs here alone. _AUDITOR_SYSTEM = ( "You audit a proposed set of grants and prohibitions. Approve only if every granted pair is " "policy-supported — REJECT any grant unsupported by the policy or the descriptions, any grant in " "a domain the candidate is not shown to act in, and any grant for a candidate the policy never " "mentions. Every prohibited pair must be a genuine explicit-prohibition or exclusivity deny, the " "exclusivity flag must be truly asserted by the SCENARIO policy, and for a purely permissive " - "policy both denied lists must be empty. When a candidate is named in BOTH lists (a conflict), " - "adjudicate it: a genuine grant-and-prohibit collision is a contradiction (report it), a mere " - "proposer slip is an ordinary rejection.\n" + _SAFETY + _MAPPING_RULES + _DENY_RULES + "policy both denied lists must be empty. Do NOT reject a proposal merely because it lists NO " + "prohibition on the focal entity: an absent focal prohibition is a defect ONLY when the SCENARIO " + "policy is exclusive/restrictive about the focal entity ITSELF (rule 6) or explicitly prohibits " + "it (rule 5). A restriction that names a DIFFERENT entity — e.g. 'Only may " + "' — is another pass's concern (rule 4); an empty prohibition list is then CORRECT and " + "must be approved, never rejected for a missing deny. When a candidate is named in BOTH lists (a " + "conflict), adjudicate it: a genuine grant-and-prohibit collision is a contradiction (report it), " + "a mere proposer slip is an ordinary rejection.\n" + _SAFETY + _MAPPING_RULES + _DENY_RULES ) diff --git a/aiac/test/agent/policy_rules_builder/test_graph_live_llm.py b/aiac/test/agent/policy_rules_builder/test_graph_live_llm.py index 6fbc57ef..129a21c0 100644 --- a/aiac/test/agent/policy_rules_builder/test_graph_live_llm.py +++ b/aiac/test/agent/policy_rules_builder/test_graph_live_llm.py @@ -241,3 +241,41 @@ def test_door_b_exclusivity_complement_denies_only(): ("source-read", DENY), ("source-write", DENY), } + + +# --------------------------------------------------------------------------- # +# Slice 7 — Door B must NOT raise a spurious 422 on SCOPE-exclusivity (#2511). # +# The policy is scope-exclusive about a DIFFERENT role ("Only developers may # +# read and modify source"); the focal role (rossoctl-admin) is a non-grantee # +# user role the policy never names, and is GRANTED nothing here. The real bug # +# #2511 fixes is the AUDITOR rejecting the proposer's proposal to exhaustion # +# (PolicyRulesBuilderError → 422) on the theory that a focal deny is "missing" — # +# it is not: that source deny is the SUBJECT gate's job (the scope-focal pass), # +# never Door B's. What this slice PINS is therefore the negative property, not # +# an exact set: the call must NOT raise, must emit NO ALLOW (Door B is # +# deny-only), and any deny it does emit must stay within the exclusive scopes # +# ({source-read, source-write}) — never leaking onto `issues`, a scope the focal # +# role has no relationship to. A duplicate DENY on the source scopes is harmless # +# here (rossoctl-admin is granted nothing, so nothing collides); the 422 is the # +# only defect. Contrast slice 6, which pins the genuine role-exclusivity deny. # +# --------------------------------------------------------------------------- # +def test_door_b_no_overreach_on_scope_exclusivity(): + admin = _role("r-adm", "rossoctl-admin", "A realm administrator, not a developer.") + issues = _scope("s-iss", "issues", "Access the issue tracker.") + source_read = _scope("s-sr", "source-read", "Read source code from the repository.") + source_write = _scope("s-sw", "source-write", "Write and modify source code in the repository.") + + # Scope-exclusive about `developers`, not about the focal role — bare prose, no enumerated + # exclusions, no default-effect scaffolding (the point of the #2504 minimization). + policy = "Only developers may read and modify source." + + # Must not raise: a PolicyRulesBuilderError (→ 422) from auditing the proposal to exhaustion is + # exactly the #2511 defect and would fail the call outright. + rules = _role_denies(policy, admin, [issues, source_read, source_write]) + + effects = _scope_effects(rules) + # No ALLOW — Door B is a DENY-only pass; an ALLOW would be a real defect. + assert all(effect is DENY for _, effect in effects), effects + # Any deny stays within the exclusive scopes; `issues` (unrelated to the focal role) is never + # denied. A duplicate source deny is a harmless echo of the scope-focal pass, not a conflict. + assert {name for name, _ in effects} <= {"source-read", "source-write"}, effects