mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-19 18:45:15 +02:00
multi_agent_chat/permissions: layer user allow-list into subagent compile
This commit is contained in:
parent
e99c06c887
commit
ef1152b80e
25 changed files with 304 additions and 62 deletions
|
|
@ -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.agents.new_chat.tools.registry import build_tools_async
|
||||||
from app.db import ChatVisibility
|
from app.db import ChatVisibility
|
||||||
from app.services.connector_service import ConnectorService
|
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 app.utils.perf import get_perf_logger
|
||||||
|
|
||||||
from ..system_prompt import build_main_agent_system_prompt
|
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),
|
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 []
|
modified_disabled_tools = list(disabled_tools) if disabled_tools else []
|
||||||
|
|
||||||
if "search_knowledge_base" not in modified_disabled_tools:
|
if "search_knowledge_base" not in modified_disabled_tools:
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ matching OpenCode's ``permission/index.ts`` evaluation order):
|
||||||
needs to *deny* what the user has explicitly forbidden; the default
|
needs to *deny* what the user has explicitly forbidden; the default
|
||||||
``ask`` fallback would otherwise double-prompt every safe read-only
|
``ask`` fallback would otherwise double-prompt every safe read-only
|
||||||
call.
|
call.
|
||||||
2. ``extra_rulesets`` — caller-supplied rulesets. Each subagent
|
2. ``subagent_rulesets`` — caller-supplied rulesets contributed by the
|
||||||
contributes its own (KB: destructive-FS ``ask`` rules; connectors:
|
consuming subagent. Each subagent passes its coded rules (KB:
|
||||||
per-tool ``allow``/``ask``).
|
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``
|
Connector deny synthesis from ``new_chat._synthesize_connector_deny_rules``
|
||||||
is intentionally NOT replicated: the multi-agent orchestrator already
|
is intentionally NOT replicated: the multi-agent orchestrator already
|
||||||
|
|
@ -36,32 +37,34 @@ _SURFSENSE_DEFAULTS = Ruleset(
|
||||||
def build_permission_mw(
|
def build_permission_mw(
|
||||||
*,
|
*,
|
||||||
flags: AgentFeatureFlags,
|
flags: AgentFeatureFlags,
|
||||||
extra_rulesets: list[Ruleset] | None = None,
|
subagent_rulesets: list[Ruleset] | None = None,
|
||||||
) -> PermissionMiddleware | None:
|
) -> PermissionMiddleware | None:
|
||||||
"""Return a configured :class:`PermissionMiddleware` or ``None`` when no work is needed.
|
"""Return a configured :class:`PermissionMiddleware` or ``None`` when no work is needed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
flags: Feature toggles. ``enable_permission`` switches the engine on;
|
flags: Feature toggles. ``enable_permission`` switches the engine on;
|
||||||
``disable_new_agent_stack`` overrides everything for safety.
|
``disable_new_agent_stack`` overrides everything for safety.
|
||||||
extra_rulesets: Caller-supplied rulesets layered after the defaults.
|
subagent_rulesets: Caller-supplied rulesets layered after the
|
||||||
Subagents pass their own ruleset here so each subagent owns its
|
defaults. Subagents pass their own coded ruleset here (and,
|
||||||
rules without aliasing a shared engine. Presence of any extra
|
when present, the user's persisted allow-list for that
|
||||||
ruleset forces the middleware on regardless of
|
subagent) so each subagent owns its own rule surface without
|
||||||
``enable_permission`` — an explicit ``ask`` rule always asks.
|
aliasing a shared engine. Presence of any subagent ruleset
|
||||||
|
forces the middleware on regardless of ``enable_permission`` —
|
||||||
|
an explicit ``ask`` rule always asks.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``None`` when the engine has no rules to enforce
|
``None`` when the engine has no rules to enforce
|
||||||
(``enable_permission=False`` and no extras); a configured middleware
|
(``enable_permission=False`` and no subagent rulesets); a
|
||||||
otherwise.
|
configured middleware otherwise.
|
||||||
"""
|
"""
|
||||||
permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack
|
permission_enabled = flags.enable_permission and not flags.disable_new_agent_stack
|
||||||
has_extras = bool(extra_rulesets)
|
has_subagent_rulesets = bool(subagent_rulesets)
|
||||||
if not (permission_enabled or has_extras):
|
if not (permission_enabled or has_subagent_rulesets):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
rulesets: list[Ruleset] = [_SURFSENSE_DEFAULTS]
|
rulesets: list[Ruleset] = [_SURFSENSE_DEFAULTS]
|
||||||
if extra_rulesets:
|
if subagent_rulesets:
|
||||||
rulesets.extend(extra_rulesets)
|
rulesets.extend(subagent_rulesets)
|
||||||
return PermissionMiddleware(rulesets=rulesets)
|
return PermissionMiddleware(rulesets=rulesets)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ def build_subagent(
|
||||||
dependencies=dependencies,
|
dependencies=dependencies,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
read_only=False,
|
read_only=False,
|
||||||
|
subagent_name=NAME,
|
||||||
ruleset=KB_RULESET,
|
ruleset=KB_RULESET,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -83,6 +84,7 @@ def build_readonly_subagent(
|
||||||
dependencies=dependencies,
|
dependencies=dependencies,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
read_only=True,
|
read_only=True,
|
||||||
|
subagent_name=READONLY_NAME,
|
||||||
ruleset=None,
|
ruleset=None,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -33,20 +33,46 @@ from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
from app.agents.new_chat.permissions import Ruleset
|
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(
|
def build_kb_middleware(
|
||||||
*,
|
*,
|
||||||
llm: BaseChatModel,
|
llm: BaseChatModel,
|
||||||
dependencies: dict[str, Any],
|
dependencies: dict[str, Any],
|
||||||
middleware_stack: dict[str, Any] | None,
|
middleware_stack: dict[str, Any] | None,
|
||||||
read_only: bool,
|
read_only: bool,
|
||||||
|
subagent_name: str,
|
||||||
ruleset: Ruleset | None = None,
|
ruleset: Ruleset | None = None,
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
"""Compose the KB subagent's middleware list.
|
"""Compose the KB subagent's middleware list.
|
||||||
|
|
||||||
``ruleset`` is the KB-owned permission ruleset (typically the
|
Args:
|
||||||
destructive-FS ask rules). When provided, a dedicated
|
subagent_name: Identity of the subagent being built (e.g.
|
||||||
:class:`PermissionMiddleware` is appended so KB enforces approval at
|
``"knowledge_base"``, ``"knowledge_base_readonly"``). Used to
|
||||||
the rule layer.
|
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 {}
|
mws = middleware_stack or {}
|
||||||
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
filesystem_mode: FilesystemMode = dependencies["filesystem_mode"]
|
||||||
|
|
@ -61,11 +87,13 @@ def build_kb_middleware(
|
||||||
)
|
)
|
||||||
if m is not None
|
if m is not None
|
||||||
]
|
]
|
||||||
permission_mw = (
|
permission_mw = None
|
||||||
build_permission_mw(flags=flags, extra_rulesets=[ruleset])
|
if ruleset is not None and flags is not None:
|
||||||
if (ruleset is not None and flags is not None)
|
rulesets: list[Ruleset] = [ruleset]
|
||||||
else None
|
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 [
|
return [
|
||||||
mws["todos"],
|
mws["todos"],
|
||||||
build_kb_context_projection_mw(),
|
build_kb_context_projection_mw(),
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=list(mcp_tools or []),
|
tools=list(mcp_tools or []),
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=list(mcp_tools or []),
|
tools=list(mcp_tools or []),
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=list(mcp_tools or []),
|
tools=list(mcp_tools or []),
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=list(mcp_tools or []),
|
tools=list(mcp_tools or []),
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=list(mcp_tools or []),
|
tools=list(mcp_tools or []),
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def build_subagent(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
ruleset=RULESET,
|
ruleset=RULESET,
|
||||||
flags=dependencies["flags"],
|
dependencies=dependencies,
|
||||||
model=model,
|
model=model,
|
||||||
middleware_stack=middleware_stack,
|
middleware_stack=middleware_stack,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,26 @@ from app.agents.multi_agent_chat.middleware.shared.permissions import (
|
||||||
build_permission_mw,
|
build_permission_mw,
|
||||||
)
|
)
|
||||||
from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
|
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
|
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(
|
def pack_subagent(
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
|
|
@ -24,23 +40,43 @@ def pack_subagent(
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
tools: list[BaseTool],
|
tools: list[BaseTool],
|
||||||
ruleset: Ruleset,
|
ruleset: Ruleset,
|
||||||
flags: AgentFeatureFlags,
|
dependencies: dict[str, Any],
|
||||||
model: BaseChatModel | None = None,
|
model: BaseChatModel | None = None,
|
||||||
middleware_stack: dict[str, Any] | None = None,
|
middleware_stack: dict[str, Any] | None = None,
|
||||||
) -> SurfSenseSubagentSpec:
|
) -> SurfSenseSubagentSpec:
|
||||||
"""Pack the route-local pieces into one sub-agent spec + its Ruleset.
|
"""Pack the route-local pieces into one sub-agent spec + its Ruleset.
|
||||||
|
|
||||||
Tool gating is uniformly performed by a per-subagent
|
Tool gating is uniformly performed by a per-subagent
|
||||||
:class:`PermissionMiddleware` built from the subagent's own
|
:class:`PermissionMiddleware`. Three rule layers are evaluated
|
||||||
``ruleset`` (layered on top of the SurfSense defaults). The shared
|
earliest-to-latest (last match wins):
|
||||||
``permission`` slot from ``middleware_stack`` is dropped so each
|
|
||||||
subagent owns its own rule surface.
|
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():
|
if not system_prompt.strip():
|
||||||
msg = f"Subagent {name!r}: system_prompt is empty"
|
msg = f"Subagent {name!r}: system_prompt is empty"
|
||||||
raise ValueError(msg)
|
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] = []
|
prepended: list[Any] = []
|
||||||
for slot, mw in (middleware_stack or {}).items():
|
for slot, mw in (middleware_stack or {}).items():
|
||||||
if mw is None:
|
if mw is None:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"""Regression: subagent-owned rulesets layer cleanly into ``PermissionMiddleware``.
|
"""Regression: subagent-owned rulesets layer cleanly into ``PermissionMiddleware``.
|
||||||
|
|
||||||
The KB unification swap (legacy ``interrupt_on`` map → KB-owned ``Ruleset``
|
The KB unification swap (legacy ``interrupt_on`` map → KB-owned ``Ruleset``
|
||||||
threaded through ``build_permission_mw(extra_rulesets=...)``) must produce
|
threaded through ``build_permission_mw(subagent_rulesets=...)``) must
|
||||||
*exactly one* interrupt per destructive FS call, in LC HITL shape, even
|
produce *exactly one* interrupt per destructive FS call, in LC HITL
|
||||||
when ``enable_permission`` is False — destructive ops always ask.
|
shape, even when ``enable_permission`` is False — destructive ops always
|
||||||
|
ask.
|
||||||
|
|
||||||
We exercise the production factory and a real ``PermissionMiddleware`` on a
|
We exercise the production factory and a real ``PermissionMiddleware`` on a
|
||||||
real ``StateGraph`` so the test catches regressions in factory gating,
|
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(
|
def _build_graph_with_permission_middleware(
|
||||||
*,
|
*,
|
||||||
flags: AgentFeatureFlags,
|
flags: AgentFeatureFlags,
|
||||||
extra_rulesets: list[Ruleset] | None,
|
subagent_rulesets: list[Ruleset] | None,
|
||||||
checkpointer: InMemorySaver,
|
checkpointer: InMemorySaver,
|
||||||
):
|
):
|
||||||
"""Compile a one-node graph that emits a tool call for ``rm`` and
|
"""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
|
``after_model`` hook intercepts and (if a rule says ``ask``) raises
|
||||||
a ``GraphInterrupt`` carrying the LC HITL payload.
|
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]:
|
def node(_state: _State) -> dict[str, Any]:
|
||||||
msg = AIMessage(
|
msg = AIMessage(
|
||||||
|
|
@ -108,10 +109,10 @@ async def test_kb_ruleset_raises_one_lc_hitl_ask_for_rm_even_when_permission_fla
|
||||||
checkpointer = InMemorySaver()
|
checkpointer = InMemorySaver()
|
||||||
graph, pm = _build_graph_with_permission_middleware(
|
graph, pm = _build_graph_with_permission_middleware(
|
||||||
flags=flags,
|
flags=flags,
|
||||||
extra_rulesets=[_kb_style_ruleset()],
|
subagent_rulesets=[_kb_style_ruleset()],
|
||||||
checkpointer=checkpointer,
|
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"}}
|
config = {"configurable": {"thread_id": "kb-cloud-rm"}}
|
||||||
await graph.ainvoke({"messages": [HumanMessage(content="seed")]}, config)
|
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()
|
checkpointer = InMemorySaver()
|
||||||
graph, _ = _build_graph_with_permission_middleware(
|
graph, _ = _build_graph_with_permission_middleware(
|
||||||
flags=flags,
|
flags=flags,
|
||||||
extra_rulesets=[_kb_style_ruleset()],
|
subagent_rulesets=[_kb_style_ruleset()],
|
||||||
checkpointer=checkpointer,
|
checkpointer=checkpointer,
|
||||||
)
|
)
|
||||||
config = {"configurable": {"thread_id": "kb-cloud-rm-approve"}}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_no_extras_with_permission_off_skips_middleware_entirely():
|
async def test_no_subagent_rulesets_with_permission_off_skips_middleware_entirely():
|
||||||
"""No extras + permission off → factory returns ``None`` (no engine).
|
"""No subagent rulesets + permission off → factory returns ``None`` (no engine).
|
||||||
|
|
||||||
The legacy gating is preserved when no caller asks for rules: nothing
|
The legacy gating is preserved when no caller asks for rules: nothing
|
||||||
runs, nothing pauses.
|
runs, nothing pauses.
|
||||||
"""
|
"""
|
||||||
flags = AgentFeatureFlags(enable_permission=False)
|
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
|
assert pm is None
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,14 @@ from langchain_core.language_models.fake_chat_models import (
|
||||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
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 (
|
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
|
||||||
pack_subagent,
|
pack_subagent,
|
||||||
)
|
)
|
||||||
from app.agents.new_chat.feature_flags import AgentFeatureFlags
|
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):
|
class RateLimitError(Exception):
|
||||||
|
|
@ -81,7 +84,7 @@ async def test_subagent_recovers_when_primary_llm_fails():
|
||||||
system_prompt="be helpful",
|
system_prompt="be helpful",
|
||||||
tools=[],
|
tools=[],
|
||||||
ruleset=Ruleset(origin="resilience_test", rules=[]),
|
ruleset=Ruleset(origin="resilience_test", rules=[]),
|
||||||
flags=AgentFeatureFlags(),
|
dependencies={"flags": AgentFeatureFlags()},
|
||||||
model=primary,
|
model=primary,
|
||||||
middleware_stack={"fallback": ModelFallbackMiddleware(fallback)},
|
middleware_stack={"fallback": ModelFallbackMiddleware(fallback)},
|
||||||
)
|
)
|
||||||
|
|
@ -99,3 +102,142 @@ async def test_subagent_recovers_when_primary_llm_fails():
|
||||||
final = result["messages"][-1]
|
final = result["messages"][-1]
|
||||||
assert isinstance(final, AIMessage)
|
assert isinstance(final, AIMessage)
|
||||||
assert final.content == "recovered via fallback"
|
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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue