Brief 1b: PermissionGate sole enforcer + graded 2D scope envelope - #476
Open
jlunder00 wants to merge 5 commits into
Open
Brief 1b: PermissionGate sole enforcer + graded 2D scope envelope#476jlunder00 wants to merge 5 commits into
jlunder00 wants to merge 5 commits into
Conversation
New interactive_agent_layer/envelope.py: ScopeEnvelope frozen dataclass with m_allowed(d) graded-detail formula, load_permission_envelope()/ load_injection_envelope() config loaders (with per-scenario partial-merge), and validate_injection_subset() enforcing injection ⊆ permission at load time (full per-distance loop, not just endpoints — clamped-linear curves can cross mid-range). Raises ScopeConfigError on violation, fail fast. Part of brief 1b (PermissionGate sole enforcer + graded 2D envelope).
config/app_config.yaml: new top-level scope.{permission,injection,scenarios}
block with inert defaults reproducing today's behavior exactly (permission
3/4/1 matches agent_layer.scope_radius default; injection 1/2/0 matches the
current flat cascade). agent_layer.permission_timeout_seconds 60 -> 120 per
config-namespace-decision-2026-07-04.md (one truth with config.py's fallback).
Adds direct-yaml assertions to test_envelope.py so the committed config is
verified against the envelope model, not just the loader mock.
…rant fix
PermissionGate now takes a ScopeEnvelope instead of a raw scope_radius int
(scope_mode param deleted, A8 — it never branched on anything). The
read_context scope check is graded: a target is out-of-scope iff its tree
distance d exceeds envelope.radius OR the tool call's requested M exceeds
envelope.m_allowed(d) (default M=4, matching read_context's own default).
Extracted one _await_user_decision(kind, target, on_timeout) helper shared
by the scope-read path and the user_action translation-table path (previously
two near-duplicate implementations). Reads deny-and-continue on timeout;
writes (user_section_edit/destructive) raise PermissionTimeoutError, unchanged
behavior. Every terminal resolution (approved/denied/timeout) now emits a
permission_resolved {request_id, resolution} event on outbound_events, on
both paths — previously neither path emitted this event at all.
Fixes DD 3.2 gap: the scope-read path previously had no grant check/insert
at all (unlike the user_action path). Both paths now check_grant_fn before
prompting and insert_grant_fn after approval, uniformly.
reason_from_bot is now plumbed from an optional tool-call 'reason' arg on
both paths (previously hardcoded None).
session.py: Session.scope_radius -> scope_envelope; options key scope_radius
-> permission_envelope (a plain dict — ScopeEnvelope itself isn't JSON-
serializable and options feeds the pool's options_hash). _resolve_scope
builds a ScopeEnvelope from the option dict when present, else falls back to
load_permission_envelope().
config.py: delete get_scope_radius() (replaced by envelope.load_permission_
envelope()); get_permission_timeout() fallback 900->120 (one truth with the
yaml default set in the previous commit).
Part of brief 1b (items 3, 5, 6 of the 7-item checklist).
Full consolidation (Jason's decision, no shim): DELETE both prior distance implementations and keep exactly one. get_node_hop_distance (nodes.py, unbounded LCA ancestor-walk) is gone; node_memory.get_node_tree_distance (bounded single-root BFS) moves to nodes.py and generalizes to accept from_ids: list[str] seeded at distance 0 — min-distance-wins across roots (DD §5.8's multi-root org-accounts model). Single-root callers pass [id]. The gate always has a radius to bound the walk by, so LCA's unbounded cost is unnecessary. db/pg_queries/__init__.py: get_node_tree_distance now re-exported from nodes, not node_memory. tests/db/test_node_hop_distance.py deleted, replaced by tests/db/test_node_tree_distance.py covering the new signature: from_id== to_id and multi-root-membership short-circuits (no DB round trip), value passthrough, and args-threaded-correctly (UUID list + max_N bound). tests/mcp/test_read_context_enforcement.py: updated the "must never be called" guard's patch target from db.pg_queries.node_memory. get_node_tree_distance to db.pg_queries.nodes.get_node_tree_distance to match the move. This is a matched pair with a tether-premium PR updating scope_grants. make_hop_distance_fn to call the new nodes.get_node_tree_distance — must merge together.
The distance-query call sites (nodes.get_node_tree_distance, premium's scope_grants.make_hop_distance_fn) bound their bounded-BFS walk to MAX_HOP_DISTANCE_BOUND=20 as max_N. A configured permission.radius beyond that bound would never actually be honored by the DB query — it would silently behave as if capped. load_permission_envelope() now raises ScopeConfigError at load time instead (fail fast, consistent with validate_injection_subset's raise-not-clamp policy). MAX_HOP_DISTANCE_BOUND now lives in envelope.py (this repo's canonical scope-config module); the premium matched-pair PR imports it from here instead of defining its own copy, so the two bounds cannot drift.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements brief 1b (with 1c's distance-query consolidation folded in): PermissionGate becomes the sole scope enforcer with a graded, config-driven 2D envelope, per
config-namespace-decision-2026-07-04.mdand the design review (§5.1, §5.4, §4.5).feature/brief-1b-scope-grants-consolidation. This PR deletesdb.pg_queries.nodes.get_node_hop_distance; the premium PR updatesscope_grants.make_hop_distance_fnto call the replacement. Merging this alone will break premium's scope-gating import.1.
interactive_agent_layer/envelope.py(new)ScopeEnvelopefrozen dataclass(radius, m_max, decay)with.m_allowed(d)graded-detail formula (Nonebeyond radius, elsemax(1, m_max - decay*d)).load_permission_envelope()/load_injection_envelope()read the newscope.*config namespace with per-scenario partial-merge.validate_injection_subset()enforcesinjection ⊆ permissionat load time (full per-distance loop — clamped-linear curves can cross mid-range, not just at endpoints), raisingScopeConfigError. Also rejects a configuredpermission.radiusbeyondMAX_HOP_DISTANCE_BOUND(20) — the distance query would never honor a larger radius.2.
config/app_config.yamlNew top-level
scope: {permission, injection, scenarios}block, inert defaults reproducing today's behavior exactly.agent_layer.permission_timeout_seconds60 → 120 (one truth withconfig.py's fallback).3. Gate graded check
PermissionGatenow takes aScopeEnvelopeinstead of a rawscope_radius: int.read_contextscope check: ask iffd > envelope.radius OR requested_M > envelope.m_allowed(d)(Marg, default 4). Fixes DD 3.2 gap: the scope-read path previously had no grant check/insert at all — both paths now check/insert grants uniformly.4. Distance-query consolidation (full, no shim — Jason's call)
get_node_hop_distance(unbounded LCA-walk) is deleted.get_node_tree_distance(bounded BFS) moves fromnode_memory.pytonodes.py, generalized to multi-root (from_ids: list[str], min-distance-wins — DD §5.8's multi-root org-accounts model).5. Unified
_await_user_decision(kind, target, on_timeout)Replaces two near-duplicate implementations. Reads → deny-and-continue on timeout; writes (
user_section_edit/destructive) → raisePermissionTimeoutError→session_timeout(unchanged behavior).reason_from_botplumbed from an optional tool-callreasonarg on both paths (previously hardcodedNone).6. Deletions/renames
get_scope_radius()deleted (replaced byload_permission_envelope()). Session-optionsscope_radius: int|None→ optionalpermission_envelopeoverride — shipped as a plain dict, not aScopeEnvelopeinstance, sinceScopeEnvelopeisn't JSON-serializable andsession.optionsfeeds the pool'soptions_hashviajson.dumps.scope_modeparam deleted (A8 — never branched on anything).auto_approve_user_actionsuntouched (Phase 4d).7. Event schema (for brief 1e, frontend)
permission_resolvedis now emitted on every terminal resolution (approved/denied/timeout) on both the scope-read and user_action paths — previously emitted on neither.Test plan
tests/interactive_agent_layer/— 226 passed (envelope: 21, permissions: 21, scope_gating: 26, session: 29, config: 3, plus unrelated suites unchanged)tests/db/test_node_tree_distance.py(new, replaces deletedtest_node_hop_distance.py) — 7 passedtests/mcp/test_read_context_enforcement.py— patch target updated for the nodes.py move, 13 passedpr-review-toolkit:code-reviewer