diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py index 6002eb3c2..b2b553b83 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/runtime/factory.py @@ -29,6 +29,7 @@ from app.agents.new_chat.tools.invalid_tool import INVALID_TOOL_NAME, invalid_to from app.agents.new_chat.tools.registry import build_tools_async from app.db import ChatVisibility from app.services.connector_service import ConnectorService +from app.services.user_tool_allowlist import fetch_user_allowlist_rulesets from app.utils.perf import get_perf_logger from ..system_prompt import build_main_agent_system_prompt @@ -146,6 +147,35 @@ async def create_multi_agent_chat_deep_agent( len(mcp_tools_by_agent), ) + # User-scoped allow-list ("Always Allow" persisted to + # ``SearchSourceConnector.config.trusted_tools``). Layered last in each + # subagent's PermissionMiddleware so user ``allow`` overrides coded + # ``ask`` via last-match-wins. Anonymous turns and read failures both + # degrade to "no user rules" rather than blocking the turn. + user_allowlist_by_subagent: dict[str, Any] = {} + if user_id: + _t0 = time.perf_counter() + try: + import uuid as _uuid + + user_allowlist_by_subagent = await fetch_user_allowlist_rulesets( + db_session, + user_id=_uuid.UUID(user_id), + search_space_id=search_space_id, + ) + except Exception as e: + logging.warning( + "User allow-list fetch failed; subagents will run without user trust rules this turn: %s", + e, + ) + user_allowlist_by_subagent = {} + _perf_log.info( + "[create_agent] fetch_user_allowlist_rulesets in %.3fs (%d subagents have rules)", + time.perf_counter() - _t0, + len(user_allowlist_by_subagent), + ) + dependencies["user_allowlist_by_subagent"] = user_allowlist_by_subagent + modified_disabled_tools = list(disabled_tools) if disabled_tools else [] if "search_knowledge_base" not in modified_disabled_tools: diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py index 00220d269..14d5d8eb7 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/factory.py @@ -9,9 +9,10 @@ matching OpenCode's ``permission/index.ts`` evaluation order): needs to *deny* what the user has explicitly forbidden; the default ``ask`` fallback would otherwise double-prompt every safe read-only call. -2. ``extra_rulesets`` — caller-supplied rulesets. Each subagent - contributes its own (KB: destructive-FS ``ask`` rules; connectors: - per-tool ``allow``/``ask``). +2. ``subagent_rulesets`` — caller-supplied rulesets contributed by the + consuming subagent. Each subagent passes its coded rules (KB: + destructive-FS ``ask`` rules; connectors: per-tool ``allow``/``ask``) + plus, when present, the user's persisted allow-list for that subagent. Connector deny synthesis from ``new_chat._synthesize_connector_deny_rules`` is intentionally NOT replicated: the multi-agent orchestrator already @@ -36,32 +37,34 @@ _SURFSENSE_DEFAULTS = Ruleset( def build_permission_mw( *, flags: AgentFeatureFlags, - extra_rulesets: list[Ruleset] | None = None, + subagent_rulesets: list[Ruleset] | None = None, ) -> PermissionMiddleware | None: """Return a configured :class:`PermissionMiddleware` or ``None`` when no work is needed. Args: flags: Feature toggles. ``enable_permission`` switches the engine on; ``disable_new_agent_stack`` overrides everything for safety. - extra_rulesets: Caller-supplied rulesets layered after the defaults. - Subagents pass their own ruleset here so each subagent owns its - rules without aliasing a shared engine. Presence of any extra - ruleset forces the middleware on regardless of - ``enable_permission`` — an explicit ``ask`` rule always asks. + subagent_rulesets: Caller-supplied rulesets layered after the + defaults. Subagents pass their own coded ruleset here (and, + when present, the user's persisted allow-list for that + subagent) so each subagent owns its own rule surface without + aliasing a shared engine. Presence of any subagent ruleset + forces the middleware on regardless of ``enable_permission`` — + an explicit ``ask`` rule always asks. Returns: ``None`` when the engine has no rules to enforce - (``enable_permission=False`` and no extras); a configured middleware - otherwise. + (``enable_permission=False`` and no subagent rulesets); a + configured middleware otherwise. """ permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack - has_extras = bool(extra_rulesets) - if not (permission_enabled or has_extras): + has_subagent_rulesets = bool(subagent_rulesets) + if not (permission_enabled or has_subagent_rulesets): return None rulesets: list[Ruleset] = [_SURFSENSE_DEFAULTS] - if extra_rulesets: - rulesets.extend(extra_rulesets) + if subagent_rulesets: + rulesets.extend(subagent_rulesets) return PermissionMiddleware(rulesets=rulesets) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py index 9fad9f93b..396e0ec79 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py index d3ed2885f..c6a0220ec 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/agent.py @@ -55,6 +55,7 @@ def build_subagent( dependencies=dependencies, middleware_stack=middleware_stack, read_only=False, + subagent_name=NAME, ruleset=KB_RULESET, ), }, @@ -83,6 +84,7 @@ def build_readonly_subagent( dependencies=dependencies, middleware_stack=middleware_stack, read_only=True, + subagent_name=READONLY_NAME, ruleset=None, ), }, diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py index 5f4256448..e6c969678 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/middleware_stack.py @@ -33,20 +33,46 @@ from app.agents.new_chat.filesystem_selection import FilesystemMode from app.agents.new_chat.permissions import Ruleset +def _kb_user_allowlist( + dependencies: dict[str, Any], subagent_name: str +) -> Ruleset | None: + """Return the user's persisted allow-rules for ``subagent_name`` if any. + + KB does not currently expose an "Always Allow" UI surface (the FE + button is MCP-only today), but the wiring is symmetrical with the + connector subagents so that adding KB trust later is a one-line + backend change. + """ + by_subagent = dependencies.get("user_allowlist_by_subagent") or {} + user_allowlist = by_subagent.get(subagent_name) + if isinstance(user_allowlist, Ruleset) and user_allowlist.rules: + return user_allowlist + return None + + def build_kb_middleware( *, llm: BaseChatModel, dependencies: dict[str, Any], middleware_stack: dict[str, Any] | None, read_only: bool, + subagent_name: str, ruleset: Ruleset | None = None, ) -> list[Any]: """Compose the KB subagent's middleware list. - ``ruleset`` is the KB-owned permission ruleset (typically the - destructive-FS ask rules). When provided, a dedicated - :class:`PermissionMiddleware` is appended so KB enforces approval at - the rule layer. + Args: + subagent_name: Identity of the subagent being built (e.g. + ``"knowledge_base"``, ``"knowledge_base_readonly"``). Used to + look up the user's persistent allow-list bucket in + ``dependencies["user_allowlist_by_subagent"]``. + ruleset: The KB-owned permission ruleset (typically the + destructive-FS ``ask`` rules). When provided, a dedicated + :class:`PermissionMiddleware` is appended so KB enforces + approval at the rule layer. The user's persistent allow-list + for ``subagent_name`` is layered after ``ruleset`` so user + ``allow`` rules override coded ``ask`` rules via + last-match-wins. """ mws = middleware_stack or {} filesystem_mode: FilesystemMode = dependencies["filesystem_mode"] @@ -61,11 +87,13 @@ def build_kb_middleware( ) if m is not None ] - permission_mw = ( - build_permission_mw(flags=flags, extra_rulesets=[ruleset]) - if (ruleset is not None and flags is not None) - else None - ) + permission_mw = None + if ruleset is not None and flags is not None: + rulesets: list[Ruleset] = [ruleset] + user_allowlist = _kb_user_allowlist(dependencies, subagent_name) + if user_allowlist is not None: + rulesets.append(user_allowlist) + permission_mw = build_permission_mw(flags=flags, subagent_rulesets=rulesets) return [ mws["todos"], build_kb_context_projection_mw(), diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py index 103bb0cfb..84ab0c2fb 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/agent.py @@ -33,7 +33,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py index 7b37b4228..37026bebd 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/agent.py @@ -33,7 +33,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py index 129952dae..d7648d407 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/agent.py @@ -37,7 +37,7 @@ def build_subagent( system_prompt=system_prompt, tools=list(mcp_tools or []), ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py index 695013d2a..7ef706c3d 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py index 1fec8aa0c..e1308a100 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/agent.py @@ -37,7 +37,7 @@ def build_subagent( system_prompt=system_prompt, tools=list(mcp_tools or []), ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py index fab6ecbbe..5e95c876d 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py index 3bac0dc02..567e72973 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py index dd5896869..d3ae6dc83 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py index fd5b64313..082400eb9 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py index 7acb7431d..fb4a24ddd 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py index 0ad47f45d..ff71d4cf7 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/agent.py @@ -37,7 +37,7 @@ def build_subagent( system_prompt=system_prompt, tools=list(mcp_tools or []), ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py index 0b0580704..d9b282f2b 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/agent.py @@ -37,7 +37,7 @@ def build_subagent( system_prompt=system_prompt, tools=list(mcp_tools or []), ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py index b1d3ecead..d84efaed8 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py index 17c8466ab..8de86b2d8 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py index 790d1a133..f7634d8ef 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py index d2426d92e..e16956b25 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/agent.py @@ -37,7 +37,7 @@ def build_subagent( system_prompt=system_prompt, tools=list(mcp_tools or []), ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py index 4e39a52f8..ab808b745 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/agent.py @@ -38,7 +38,7 @@ def build_subagent( system_prompt=system_prompt, tools=tools, ruleset=RULESET, - flags=dependencies["flags"], + dependencies=dependencies, model=model, middleware_stack=middleware_stack, ) diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py index 76a6f14f3..c61691405 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/subagent_builder.py @@ -13,10 +13,26 @@ from app.agents.multi_agent_chat.middleware.shared.permissions import ( build_permission_mw, ) from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec -from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.new_chat.permissions import Ruleset +def _user_allowlist_for( + dependencies: dict[str, Any], subagent_name: str +) -> Ruleset | None: + """Return the user's persisted allow-rules for ``subagent_name`` if any. + + Populated by the agent factory from + :func:`app.services.user_tool_allowlist.fetch_user_allowlist_rulesets`. + Returning ``None`` is the common case (fresh accounts, non-MCP + subagents, or no "Always Allow" interactions yet). + """ + by_subagent = dependencies.get("user_allowlist_by_subagent") or {} + user_allowlist = by_subagent.get(subagent_name) + if isinstance(user_allowlist, Ruleset) and user_allowlist.rules: + return user_allowlist + return None + + def pack_subagent( *, name: str, @@ -24,23 +40,43 @@ def pack_subagent( system_prompt: str, tools: list[BaseTool], ruleset: Ruleset, - flags: AgentFeatureFlags, + dependencies: dict[str, Any], model: BaseChatModel | None = None, middleware_stack: dict[str, Any] | None = None, ) -> SurfSenseSubagentSpec: """Pack the route-local pieces into one sub-agent spec + its Ruleset. Tool gating is uniformly performed by a per-subagent - :class:`PermissionMiddleware` built from the subagent's own - ``ruleset`` (layered on top of the SurfSense defaults). The shared - ``permission`` slot from ``middleware_stack`` is dropped so each - subagent owns its own rule surface. + :class:`PermissionMiddleware`. Three rule layers are evaluated + earliest-to-latest (last match wins): + + 1. SurfSense defaults — single ``allow */*`` rule (added by + :func:`build_permission_mw`). + 2. ``ruleset`` — the subagent's coded approval rules (e.g. KB's + destructive-FS ``ask`` rules, connector ``ask`` writes). + 3. The user's persisted allow-list for this subagent — pulled from + ``dependencies['user_allowlist_by_subagent'][name]``. User + ``allow`` rules layered last override coded ``ask`` rules, + implementing the "Always Allow" UX without re-asking on the + next turn. + + The shared ``permission`` slot from ``middleware_stack`` is dropped + so each subagent owns its own rule surface and cannot accidentally + share state with the main agent's permission middleware. """ if not system_prompt.strip(): msg = f"Subagent {name!r}: system_prompt is empty" raise ValueError(msg) - per_subagent_perm = build_permission_mw(flags=flags, extra_rulesets=[ruleset]) + flags = dependencies["flags"] + user_allowlist = _user_allowlist_for(dependencies, name) + subagent_rulesets: list[Ruleset] = [ruleset] + if user_allowlist is not None: + subagent_rulesets.append(user_allowlist) + per_subagent_perm = build_permission_mw( + flags=flags, subagent_rulesets=subagent_rulesets + ) + prepended: list[Any] = [] for slot, mw in (middleware_stack or {}).items(): if mw is None: diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_subagent_owned_ruleset.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_subagent_owned_ruleset.py index 43ca83aff..6f3f34536 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_subagent_owned_ruleset.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_subagent_owned_ruleset.py @@ -1,9 +1,10 @@ """Regression: subagent-owned rulesets layer cleanly into ``PermissionMiddleware``. The KB unification swap (legacy ``interrupt_on`` map → KB-owned ``Ruleset`` -threaded through ``build_permission_mw(extra_rulesets=...)``) must produce -*exactly one* interrupt per destructive FS call, in LC HITL shape, even -when ``enable_permission`` is False — destructive ops always ask. +threaded through ``build_permission_mw(subagent_rulesets=...)``) must +produce *exactly one* interrupt per destructive FS call, in LC HITL +shape, even when ``enable_permission`` is False — destructive ops always +ask. We exercise the production factory and a real ``PermissionMiddleware`` on a real ``StateGraph`` so the test catches regressions in factory gating, @@ -54,7 +55,7 @@ class _State(TypedDict, total=False): def _build_graph_with_permission_middleware( *, flags: AgentFeatureFlags, - extra_rulesets: list[Ruleset] | None, + subagent_rulesets: list[Ruleset] | None, checkpointer: InMemorySaver, ): """Compile a one-node graph that emits a tool call for ``rm`` and @@ -64,7 +65,7 @@ def _build_graph_with_permission_middleware( ``after_model`` hook intercepts and (if a rule says ``ask``) raises a ``GraphInterrupt`` carrying the LC HITL payload. """ - pm = build_permission_mw(flags=flags, extra_rulesets=extra_rulesets) + pm = build_permission_mw(flags=flags, subagent_rulesets=subagent_rulesets) def node(_state: _State) -> dict[str, Any]: msg = AIMessage( @@ -108,10 +109,10 @@ async def test_kb_ruleset_raises_one_lc_hitl_ask_for_rm_even_when_permission_fla checkpointer = InMemorySaver() graph, pm = _build_graph_with_permission_middleware( flags=flags, - extra_rulesets=[_kb_style_ruleset()], + subagent_rulesets=[_kb_style_ruleset()], checkpointer=checkpointer, ) - assert pm is not None, "extras must force the middleware on" + assert pm is not None, "subagent rulesets must force the middleware on" config = {"configurable": {"thread_id": "kb-cloud-rm"}} await graph.ainvoke({"messages": [HumanMessage(content="seed")]}, config) @@ -136,7 +137,7 @@ async def test_kb_ruleset_resume_with_approve_lets_rm_through(): checkpointer = InMemorySaver() graph, _ = _build_graph_with_permission_middleware( flags=flags, - extra_rulesets=[_kb_style_ruleset()], + subagent_rulesets=[_kb_style_ruleset()], checkpointer=checkpointer, ) config = {"configurable": {"thread_id": "kb-cloud-rm-approve"}} @@ -158,12 +159,12 @@ async def test_kb_ruleset_resume_with_approve_lets_rm_through(): @pytest.mark.asyncio -async def test_no_extras_with_permission_off_skips_middleware_entirely(): - """No extras + permission off → factory returns ``None`` (no engine). +async def test_no_subagent_rulesets_with_permission_off_skips_middleware_entirely(): + """No subagent rulesets + permission off → factory returns ``None`` (no engine). The legacy gating is preserved when no caller asks for rules: nothing runs, nothing pauses. """ flags = AgentFeatureFlags(enable_permission=False) - pm = build_permission_mw(flags=flags, extra_rulesets=None) + pm = build_permission_mw(flags=flags, subagent_rulesets=None) assert pm is None diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py index 4dc98665c..062ea92ec 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/subagents/shared/test_subagent_builder.py @@ -19,11 +19,14 @@ from langchain_core.language_models.fake_chat_models import ( from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from langchain_core.outputs import ChatGeneration, ChatResult +from app.agents.multi_agent_chat.middleware.shared.permissions.middleware.core import ( + PermissionMiddleware, +) from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) from app.agents.new_chat.feature_flags import AgentFeatureFlags -from app.agents.new_chat.permissions import Ruleset +from app.agents.new_chat.permissions import Rule, Ruleset, evaluate class RateLimitError(Exception): @@ -81,7 +84,7 @@ async def test_subagent_recovers_when_primary_llm_fails(): system_prompt="be helpful", tools=[], ruleset=Ruleset(origin="resilience_test", rules=[]), - flags=AgentFeatureFlags(), + dependencies={"flags": AgentFeatureFlags()}, model=primary, middleware_stack={"fallback": ModelFallbackMiddleware(fallback)}, ) @@ -99,3 +102,142 @@ async def test_subagent_recovers_when_primary_llm_fails(): final = result["messages"][-1] assert isinstance(final, AIMessage) assert final.content == "recovered via fallback" + + +def _extract_permission_mw(spec) -> PermissionMiddleware: + """Find the lone PermissionMiddleware in a subagent's middleware list.""" + matches = [m for m in spec["middleware"] if isinstance(m, PermissionMiddleware)] + assert len(matches) == 1, "expected exactly one PermissionMiddleware" + return matches[0] + + +def test_user_allowlist_overrides_coded_ask_via_last_match_wins(): + """User ``allow`` rules promoted via "Always Allow" must beat coded ``ask`` rules.""" + coded = Ruleset( + origin="connector", + rules=[Rule(permission="save_issue", pattern="*", action="ask")], + ) + user_allowlist = Ruleset( + origin="user_allowlist:connector", + rules=[Rule(permission="save_issue", pattern="*", action="allow")], + ) + + result = pack_subagent( + name="connector", + description="test connector", + system_prompt="x", + tools=[], + ruleset=coded, + dependencies={ + "flags": AgentFeatureFlags(), + "user_allowlist_by_subagent": {"connector": user_allowlist}, + }, + ) + + mw = _extract_permission_mw(result.spec) + decided = evaluate("save_issue", "*", *mw._static_rulesets) + assert decided.action == "allow", ( + f"user_allowlist must override coded ask; got {decided!r}" + ) + + +def test_coded_ask_stays_when_user_allowlist_unrelated(): + """User ``allow`` rules for OTHER tools must not leak into asked-tools.""" + coded = Ruleset( + origin="connector", + rules=[Rule(permission="delete_issue", pattern="*", action="ask")], + ) + user_allowlist = Ruleset( + origin="user_allowlist:connector", + rules=[Rule(permission="save_issue", pattern="*", action="allow")], + ) + + result = pack_subagent( + name="connector", + description="test", + system_prompt="x", + tools=[], + ruleset=coded, + dependencies={ + "flags": AgentFeatureFlags(), + "user_allowlist_by_subagent": {"connector": user_allowlist}, + }, + ) + + mw = _extract_permission_mw(result.spec) + decided = evaluate("delete_issue", "*", *mw._static_rulesets) + assert decided.action == "ask" + + +def test_missing_user_allowlist_keeps_coded_behaviour(): + """``dependencies`` without ``user_allowlist_by_subagent`` is the common case.""" + coded = Ruleset( + origin="connector", + rules=[Rule(permission="save_issue", pattern="*", action="ask")], + ) + + result = pack_subagent( + name="connector", + description="test", + system_prompt="x", + tools=[], + ruleset=coded, + dependencies={"flags": AgentFeatureFlags()}, + ) + + mw = _extract_permission_mw(result.spec) + decided = evaluate("save_issue", "*", *mw._static_rulesets) + assert decided.action == "ask" + + +def test_user_allowlist_for_different_subagent_does_not_leak(): + """User trust for ``linear`` must not affect a ``jira`` subagent compile.""" + coded = Ruleset( + origin="jira", + rules=[Rule(permission="save_issue", pattern="*", action="ask")], + ) + linear_allowlist = Ruleset( + origin="user_allowlist:linear", + rules=[Rule(permission="save_issue", pattern="*", action="allow")], + ) + + result = pack_subagent( + name="jira", + description="test", + system_prompt="x", + tools=[], + ruleset=coded, + dependencies={ + "flags": AgentFeatureFlags(), + "user_allowlist_by_subagent": {"linear": linear_allowlist}, + }, + ) + + mw = _extract_permission_mw(result.spec) + decided = evaluate("save_issue", "*", *mw._static_rulesets) + assert decided.action == "ask" + + +def test_empty_user_allowlist_is_tolerated(): + """An empty ``Ruleset`` (no rules) must not flip evaluation to allow-everything.""" + coded = Ruleset( + origin="connector", + rules=[Rule(permission="save_issue", pattern="*", action="ask")], + ) + empty = Ruleset(origin="user_allowlist:connector", rules=[]) + + result = pack_subagent( + name="connector", + description="test", + system_prompt="x", + tools=[], + ruleset=coded, + dependencies={ + "flags": AgentFeatureFlags(), + "user_allowlist_by_subagent": {"connector": empty}, + }, + ) + + mw = _extract_permission_mw(result.spec) + decided = evaluate("save_issue", "*", *mw._static_rulesets) + assert decided.action == "ask"