feat(permissions): capability-based permission engine with ADK + LangGraph adapters#72
Merged
feat(permissions): capability-based permission engine with ADK + LangGraph adapters#72
Conversation
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 '*'.
…ow that all modules exist
…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
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 '/**'
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
Replaces the old
PermissionLevel/ConfirmationPlugin/_wrap_for_confirmationstack with a framework-independent permission engine plus thin ADK and LangGraph adapters.[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).Allow once / Allow for this session / Allow always (save to project) / Deny).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.*andkb.*are allowed by default; shell / HTTP / python execution prompt on first use.PermissionLevel,permission_levelkwarg/field,ConfirmationPlugin,_wrap_for_confirmation,ApprovalManager,workflow/confirmation.py,tools/hitl_tools.py,hitl_enabledsetting.Key changes
src/agentic_cli/workflow/permissions/(capabilities.py,rules.py,matchers.py,store.py,prompt.py,engine.py).workflow/adk/permission_plugin.py(PermissionPlugin) andworkflow/langgraph/permission_wrap.py(wrap_tool_for_permission).ToolDefinition.capabilitiesis a required field (EXEMPTsentinel for tools with no side effect on external resources).@register_tool(capabilities=...)keyword is required — omitting it raisesTypeError.BaseWorkflowManager._ensure_managers_initializedis offloaded to a worker thread viaasyncio.to_threadso the embedding-model load doesn't block the prompt on startup.Design + implementation plan
docs/superpowers/specs/2026-04-18-permissions-system-design.mddocs/superpowers/plans/2026-04-18-permissions-system.mddocs/which is gitignored; kept locally for reference.)Test plan
PermissionPluginand LangGraphwrap_tool_for_permission(EXEMPT / missing declaration / engine allow / engine deny / engine absent fallback).BaseWorkflowManagerwiring test (engine published into service registry).permissions_enableddefault True;permissionsnested config;hitl_enabledremoved).http.readwith "Allow always" now matches subsequent calls in same and new sessions).develop(net −2 reflects retired tests for deleted code, partially offset by new ones).research_demo— prompt flow, persistence to projectsettings.json, directory-scoped grants, wildcard allows, agent-to-agent transfers, startup responsiveness all behave as designed.