Skip to content

feat(layer): read_out_of_scope scope gating for read_context via PermissionGate#455

Open
jlunder00 wants to merge 1 commit into
devfrom
feature/scope-gating
Open

feat(layer): read_out_of_scope scope gating for read_context via PermissionGate#455
jlunder00 wants to merge 1 commit into
devfrom
feature/scope-gating

Conversation

@jlunder00

Copy link
Copy Markdown
Owner

Summary

  • A — PermissionGate scope context: Adds optional scope_source_node_id, scope_radius, scope_mode, hop_distance_fn, resolve_node_path_fn to PermissionGate.__init__. All default to None — existing callers are fully backwards-compatible.
  • B — Hop distance query: Adds get_node_hop_distance(conn, from_node_id, to_node_id) -> int | None to db/pg_queries/nodes.py. Uses an LCA-based recursive CTE over context_nodes. Returns None when nodes are in separate trees.
  • C — read_context scope check: When scope params are set, can_use_tool("read_context", ...) checks all target node_ids (and resolves paths) against the hop distance limit. First offender triggers permission_request(kind="read_out_of_scope") and awaits user approval.

Design decisions

IAL has no DB pool: Scope checking is injected as callables (hop_distance_fn, resolve_node_path_fn) — pre-bound to a DB connection in production wiring, mocked in tests. Same pattern as check_grant_fn/insert_grant_fn.

Path bypass prevention: read_context accepts both node_ids and paths. Both are scope-checked. Unresolvable paths (resolver returns None, or no resolver provided) are treated as out-of-scope to prevent bypass via path arguments.

Distance metric: Undirected tree distance (shortest path via LCA). scope_mode defaults to "tree_distance"; "descendant_only" is reserved for future activation.

Session options keys: scope_source_node_id and scope_radius in session.options. No existing callers set these today — bot dispatcher and frontend session-start will populate them in a follow-on PR when scope gating is wired end-to-end.

Performance note

get_node_hop_distance uses a recursive CTE that scans O(depth) rows per node. For large context trees, consider adding an ltree column or a closure table to reduce this to O(1) lookups. Not an issue at current scale but worth tracking as the tree grows.

Test plan

  • 16 new tests/interactive_agent_layer/test_scope_gating.py — no-scope pass-through, in-scope allow, out-of-scope prompt, None-distance prompt, path resolution, path bypass prevention, multi-target first-offender, timeout deny, pending cleanup
  • 8 new tests/db/test_node_hop_distance.py — mocked asyncpg, no DATABASE_URL required; covers distance cases 0/1/2/4/None, Decimal coercion, UUID arg contract
  • All 165 existing IAL tests pass — zero regressions

Implements M-hop scope enforcement for read_context tool calls:

- PermissionGate gains optional scope params (source_node_id, scope_radius,
  scope_mode, hop_distance_fn, resolve_node_path_fn); None = no enforcement,
  so all existing callers are unaffected.
- When scope is active: read_context targets (node_ids + resolved paths) are
  checked against the session's source node via undirected tree distance.
  Targets beyond M hops (or unresolvable) trigger a permission_request with
  kind="read_out_of_scope" and await user approval before proceeding.
- add get_node_hop_distance(conn, from_id, to_id) to db/pg_queries/nodes.py:
  LCA-based recursive CTE over context_nodes; returns int hops or None for
  unrelated trees. Performance note: add ltree/closure table for large trees.
- Session options keys: scope_source_node_id, scope_radius. Bot dispatcher
  and frontend session-start will populate these in a follow-on PR.
- scope_mode defaults to "tree_distance" (undirected); "descendant_only"
  flag is reserved for future use.
- 24 new tests (16 PermissionGate, 8 hop distance); all 165 IAL tests pass.
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