Skip to content

feat(permissions): capability-based permission engine with ADK + LangGraph adapters#72

Merged
shoom1 merged 35 commits intodevelopfrom
feature/permissions-system
Apr 18, 2026
Merged

feat(permissions): capability-based permission engine with ADK + LangGraph adapters#72
shoom1 merged 35 commits intodevelopfrom
feature/permissions-system

Conversation

@shoom1
Copy link
Copy Markdown
Owner

@shoom1 shoom1 commented Apr 18, 2026

Summary

Replaces the old PermissionLevel / ConfirmationPlugin / _wrap_for_confirmation stack with a framework-independent permission engine plus thin ADK and LangGraph adapters.

  • Tools declare capabilities like [Capability("filesystem.write", target_arg="path")] at registration; the engine evaluates those at call time against rules drawn from four sources (builtin defaults, user ~/.{app_name}/settings.json, project ./.{app_name}/settings.json, in-memory session).
  • DENY wins; ALLOW short-circuits; UNMATCHED prompts the user via the existing HITL channel (Allow once / Allow for this session / Allow always (save to project) / Deny).
  • "Allow for session" / "Allow always" on a filesystem.* capability broadens the stored target to the parent directory (/foo/**), so one grant covers every file in that folder — one prompt, not one per file.
  • memory.* and kb.* are allowed by default; shell / HTTP / python execution prompt on first use.
  • Full cleanup of retired symbols: PermissionLevel, permission_level kwarg/field, ConfirmationPlugin, _wrap_for_confirmation, ApprovalManager, workflow/confirmation.py, tools/hitl_tools.py, hitl_enabled setting.

Key changes

  • New package: src/agentic_cli/workflow/permissions/ (capabilities.py, rules.py, matchers.py, store.py, prompt.py, engine.py).
  • New adapters: workflow/adk/permission_plugin.py (PermissionPlugin) and workflow/langgraph/permission_wrap.py (wrap_tool_for_permission).
  • ToolDefinition.capabilities is a required field (EXEMPT sentinel for tools with no side effect on external resources).
  • @register_tool(capabilities=...) keyword is required — omitting it raises TypeError.
  • Every shipped tool now declares capabilities (27 registrations across 14 files).
  • BaseWorkflowManager._ensure_managers_initialized is offloaded to a worker thread via asyncio.to_thread so the embedding-model load doesn't block the prompt on startup.

Design + implementation plan

  • Spec: docs/superpowers/specs/2026-04-18-permissions-system-design.md
  • Plan: docs/superpowers/plans/2026-04-18-permissions-system.md
  • (Both under docs/ which is gitignored; kept locally for reference.)

Test plan

  • Unit tests for capability declarations, rule types, matchers (Path/URL/Shell/StringGlob), store (JSON round-trip, BUILTIN_RULES, atomic append), prompt (build/parse round-trip), engine (rule-based allow/deny, ask flow for all four scopes, concurrency lock, filesystem target broadening).
  • Adapter tests for ADK PermissionPlugin and LangGraph wrap_tool_for_permission (EXEMPT / missing declaration / engine allow / engine deny / engine absent fallback).
  • BaseWorkflowManager wiring test (engine published into service registry).
  • Settings test (permissions_enabled default True; permissions nested config; hitl_enabled removed).
  • Regression tests for wildcard-target bug (targetless http.read with "Allow always" now matches subsequent calls in same and new sessions).
  • Regression test for filesystem directory broadening (writing multiple files to the same directory after one "Allow always" prompts only once; nested subdirs also covered).
  • Full suite: 1459 passed, 2 skipped, 26 xfailed on this branch vs. 1461 passed / 2 skipped / 26 xfailed on develop (net −2 reflects retired tests for deleted code, partially offset by new ones).
  • Extensive manual smoke test with research_demo — prompt flow, persistence to project settings.json, directory-scoped grants, wildcard allows, agent-to-agent transfers, startup responsiveness all behave as designed.

shoom1 added 30 commits April 18, 2026 09:52
Guard sibling imports in __init__.py with try/except so each task's
tests can run in isolation before all modules are implemented.
Add PathMatcher class and _glob_to_regex helper to matchers.py.
Implements load_rules() in store.py that parses the permissions.allow/deny
arrays from a settings.json file, canonicalizes targets via get_matcher(),
and returns typed Rule objects. Uses a local import of get_matcher to avoid
a circular dependency with matchers.py. Four new tests cover: missing file,
missing section, allow+deny parsing with substitution, and malformed JSON.
…ort-circuit

Implements Task 14: PermissionEngine.__init__, rule loading from builtin/user/
project sources, and the permissions_enabled=False early-return path.
Uses Rule constructor copies (not object.__setattr__) to avoid mutating
the module-level BUILTIN_RULES frozen dataclasses.
Implement check(), _resolve(), _evaluate(), _fmt_rule_reason(), and
_ask_and_apply() stub in PermissionEngine. DENY wins across both
capabilities and sources; targetless capabilities match '*'.
…arg (staged)

Optional kwarg with default=EXEMPT; existing permission_level kwarg untouched
for staged migration. Tools will be updated to declare capabilities in Task 19;
permission_level + PermissionLevel enum get removed in the final cleanup.
Maps each tool's side effects to (capability, target) tuples. Keeps
permission_level= intact for staged migration; old field is removed
in the final cleanup task.
…+ PERMISSION_ENGINE key

- Add PERMISSION_ENGINE = "permission_engine" constant to service_registry.py (alphabetical order)
- Construct PermissionEngine unconditionally in _ensure_managers_initialized (before WORKFLOW)
- Update permission_plugin.py and permission_wrap.py to import and use the real constant
- Update existing permission integration tests to use the imported PERMISSION_ENGINE constant
- Add tests/workflow/test_base_manager_permissions.py verifying engine is published to services dict
…aged)

Adds PermissionRuleConfig, PermissionsConfig models and two new fields
(permissions, permissions_enabled) to WorkflowSettingsMixin. hitl_enabled
is preserved for migration compatibility.
…tionPlugin, _wrap_for_confirmation, hitl_tools, hitl_enabled)

- Drop permission_level= from every tool; capabilities= is now required
- Remove PermissionLevel enum + permission_level field from ToolDefinition
- Delete workflow/confirmation.py and tools/hitl_tools.py
- Strip ConfirmationPlugin from workflow/adk/plugins.py (LLMLoggingPlugin preserved)
- Remove LangGraphBuilder._wrap_for_confirmation + its confirmation imports
- Remove hitl_enabled setting
- Update tests that referenced the retired symbols
- Update README + CLAUDE.md snippets to the new API
…ches

Targetless capabilities (Capability with target_arg=None, used by web_search,
save_memory, etc.) resolve to target='*'. When a user picked 'Allow always',
URLMatcher.canonicalize was mangling '*' into 'https://*' on JSON reload,
and URLMatcher.matches rejected the bare '*' on schema-comparison, so the
just-granted rule didn't match subsequent calls — the prompt reappeared.

Fix: every matcher short-circuits the '*' sentinel in both canonicalize
(pass-through) and matches (wildcard pattern matches anything). Narrow
patterns still correctly deny targetless ('*') calls — only pattern=='*'
is treated as the universal wildcard.

Adds TestTargetlessAllowAlwaysRegression covering:
- Allow-always on web_search (http.read, targetless) → next two calls
  are allowed without re-prompting
- Cross-tool sharing: search_arxiv with same capability is also covered
- After project JSON reload, the persisted wildcard rule still matches
shoom1 added 5 commits April 18, 2026 12:26
save_plan / get_plan / save_tasks / get_tasks in tools/{adk,langgraph}/state_tools.py
are injected into agent tool lists by BaseWorkflowManager._get_state_tools() and were
never decorated with @register_tool. The adapter therefore saw defn is None and denied
every call with 'tool has no capability declaration'.

These tools only touch internal workflow state (plan string, task list) — no file,
network, or shell side effects — so EXEMPT is the right classification.
…ransfer_to_agent as EXEMPT

- BUILTIN_RULES now includes 'memory.* → *' and 'kb.* → *' ALLOW entries.
  Memory + KB are agent-internal stores the user already opted into by
  giving the tool. Prompting for every save_memory / kb_ingest is noisy.
- ADK auto-injects transfer_to_agent into coordinator agents with sub_agents;
  it's an internal routing primitive, not an external side effect. Register
  it with EXEMPT at permission_plugin.py import time so the plugin lets it
  through.
- Tests for both new behaviours.
EmbeddingService construction loads a sentence-transformers model, which
takes 1-3 seconds of pure sync CPU/IO. It ran on the main event loop
inside initialize_services(), blocking prompt_toolkit's key handler
during background init — the REPL appeared frozen for seconds at startup
and keystrokes only rendered after init completed.

Run _ensure_managers_initialized() via asyncio.to_thread so the event
loop stays free. Prompt responsiveness is restored; the PermissionEngine
construction (which also does a small amount of sync I/O) rides along.
When the user picks 'Allow for session' or 'Allow always' on a filesystem.*
capability, store a rule covering the parent directory (with /** glob)
instead of the exact file. One grant then covers every file the agent
writes or reads in that folder, which matches typical workflows — if an
agent is writing one file, it's almost always going to write more.

Non-filesystem namespaces (http.*, shell.*, etc.) keep exact-target
grants. The wildcard sentinel '*' (used by targetless capabilities like
web_search) also passes through unchanged.

The ask prompt now prints '(Session/Always grants apply to the parent
directory.)' when a filesystem capability is involved so the user knows
what scope they're agreeing to.

Tests cover the broadening, scope preservation for other namespaces,
and nested-subdirectory matching.
The UI was displaying the exact filename (e.g. 'filesystem.write →
/foo/bar.txt'), but Session/Always grants store /foo/**. Show the
broadened target in the prompt so the displayed scope matches the scope
that will actually be granted.

- Moved the widening helper to a module-level function
  (engine.broaden_target_for_grant) so both engine._ask_and_apply and
  prompt.build_request use the same logic.
- prompt.build_request now displays broaden_target_for_grant(cap) for
  each capability line. A '(Grant scope widened to the parent
  directory.)' hint replaces the previous 'applies to parent directory'
  wording and only appears when any capability was actually broadened.
- Handle the root-parent edge case: a file directly under / collapses
  to '/**' instead of '//**'.

Tests cover:
- Filesystem capabilities display the /parent/** scope
- Exact filenames do NOT appear in the prompt
- Non-filesystem (http.*) capabilities keep exact targets
- Root-parent paths render as '/**'
@shoom1 shoom1 merged commit a07aca7 into develop Apr 18, 2026
@shoom1 shoom1 deleted the feature/permissions-system branch April 18, 2026 18:04
@shoom1 shoom1 mentioned this pull request Apr 19, 2026
2 tasks
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