multi_agent_chat/permissions: layer user allow-list into subagent compile

This commit is contained in:
CREDO23 2026-05-14 21:57:38 +02:00
parent e99c06c887
commit ef1152b80e
25 changed files with 304 additions and 62 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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,
)

View file

@ -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,
),
},

View file

@ -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(),

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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: