Skip to content

Brief 1b: PermissionGate sole enforcer + graded 2D scope envelope - #476

Open
jlunder00 wants to merge 5 commits into
devfrom
feature/brief-1b-gate-envelope
Open

Brief 1b: PermissionGate sole enforcer + graded 2D scope envelope#476
jlunder00 wants to merge 5 commits into
devfrom
feature/brief-1b-gate-envelope

Conversation

@jlunder00

Copy link
Copy Markdown
Owner

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.md and the design review (§5.1, §5.4, §4.5).

⚠️ MATCHED PAIR — must merge together with tether-premium#feature/brief-1b-scope-grants-consolidation. This PR deletes db.pg_queries.nodes.get_node_hop_distance; the premium PR updates scope_grants.make_hop_distance_fn to call the replacement. Merging this alone will break premium's scope-gating import.

1. interactive_agent_layer/envelope.py (new)

ScopeEnvelope frozen dataclass (radius, m_max, decay) with .m_allowed(d) graded-detail formula (None beyond radius, else max(1, m_max - decay*d)). load_permission_envelope() / load_injection_envelope() read the new scope.* config namespace with per-scenario partial-merge. validate_injection_subset() enforces injection ⊆ permission at load time (full per-distance loop — clamped-linear curves can cross mid-range, not just at endpoints), raising ScopeConfigError. Also rejects a configured permission.radius beyond MAX_HOP_DISTANCE_BOUND (20) — the distance query would never honor a larger radius.

2. config/app_config.yaml

New top-level scope: {permission, injection, scenarios} block, inert defaults reproducing today's behavior exactly. agent_layer.permission_timeout_seconds 60 → 120 (one truth with config.py's fallback).

3. Gate graded check

PermissionGate now takes a ScopeEnvelope instead of a raw scope_radius: int. read_context scope check: ask iff d > envelope.radius OR requested_M > envelope.m_allowed(d) (M arg, 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 from node_memory.py to nodes.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) → raise PermissionTimeoutErrorsession_timeout (unchanged behavior). reason_from_bot plumbed from an optional tool-call reason arg on both paths (previously hardcoded None).

6. Deletions/renames

get_scope_radius() deleted (replaced by load_permission_envelope()). Session-options scope_radius: int|None → optional permission_envelope override — shipped as a plain dict, not a ScopeEnvelope instance, since ScopeEnvelope isn't JSON-serializable and session.options feeds the pool's options_hash via json.dumps. scope_mode param deleted (A8 — never branched on anything). auto_approve_user_actions untouched (Phase 4d).

7. Event schema (for brief 1e, frontend)

permission_request  {type, session_id, request_id, kind, target, reason_from_bot}
permission_resolved {type, request_id, resolution: approved|denied|timeout}   # NEW
session_timeout      {type, session_id, reason, request_id}                   # unchanged

permission_resolved is 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 deleted test_node_hop_distance.py) — 7 passed
  • tests/mcp/test_read_context_enforcement.py — patch target updated for the nodes.py move, 13 passed
  • Full offline suite: 805 passed, 32 skipped (skips are DB-integration tests requiring live Postgres)
  • Self-reviewed via pr-review-toolkit:code-reviewer

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant