multi_agent_chat: scope MCP allow/ask permissions per subagent + drop "policy" synonym

This commit is contained in:
CREDO23 2026-05-14 18:09:14 +02:00
parent 0723702320
commit 67142e68b1
21 changed files with 117 additions and 180 deletions

View file

@ -9,7 +9,7 @@ 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 policies. The KB subagent contributes
2. ``extra_rulesets`` caller-supplied rulesets. The KB subagent contributes
its destructive-FS ``ask`` rules here; connectors will follow once
they migrate off ``interrupt_on``.
@ -44,7 +44,7 @@ def build_permission_mw(
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 policy here so each subagent owns its
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.

View file

@ -1,7 +1,7 @@
"""``knowledge_base`` route: full and read-only ``SurfSenseSubagentSpec`` builders.
KB owns the destructive-FS approval policy: rules live in :data:`KB_RULESET`
and are layered into KB's :class:`PermissionMiddleware` (built inside
KB owns its destructive-FS approval ruleset (:data:`KB_RULESET`); rules
are layered into KB's :class:`PermissionMiddleware` (built inside
``build_kb_middleware``). The legacy ``interrupt_on`` kwarg is gone one
emitter, one wire format, one source of truth.
"""

View file

@ -44,7 +44,7 @@ def build_kb_middleware(
) -> list[Any]:
"""Compose the KB subagent's middleware list.
``ruleset`` is the KB-owned permission policy (typically the
``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 instead of the legacy ``interrupt_on`` kwarg.

View file

@ -1 +1 @@
"""Route-local tool policy for the ``knowledge_base`` subagent."""
"""Route-local tool permissions for the ``knowledge_base`` subagent."""

View file

@ -1,4 +1,4 @@
"""Route-local FS tool policy.
"""Route-local FS tool permissions.
The KB subagent's actual ``BaseTool`` instances are provided at runtime by
``SurfSenseFilesystemMiddleware`` (mounted in ``agent.py``). This module

View file

@ -11,4 +11,17 @@ def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
_ = {**(dependencies or {}), **kwargs}
return {"allow": [], "ask": []}
return {
"allow": [
{"name": "list_bases"},
{"name": "search_bases"},
{"name": "list_tables_for_base"},
{"name": "get_table_schema"},
{"name": "list_records_for_table"},
{"name": "search_records"},
],
"ask": [
{"name": "create_records_for_table"},
{"name": "update_records_for_table"},
],
}

View file

@ -11,4 +11,16 @@ def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
_ = {**(dependencies or {}), **kwargs}
return {"allow": [], "ask": []}
return {
"allow": [
{"name": "clickup_search"},
{"name": "clickup_get_task"},
{"name": "clickup_get_workspace_hierarchy"},
{"name": "clickup_get_list"},
{"name": "clickup_find_member_by_name"},
],
"ask": [
{"name": "clickup_create_task"},
{"name": "clickup_update_task"},
],
}

View file

@ -11,4 +11,20 @@ def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
_ = {**(dependencies or {}), **kwargs}
return {"allow": [], "ask": []}
return {
"allow": [
{"name": "getAccessibleAtlassianResources"},
{"name": "getVisibleJiraProjects"},
{"name": "searchJiraIssuesUsingJql"},
{"name": "getJiraIssue"},
{"name": "getJiraProjectIssueTypesMetadata"},
{"name": "getJiraIssueTypeMetaWithFields"},
{"name": "getTransitionsForJiraIssue"},
{"name": "lookupJiraAccountId"},
],
"ask": [
{"name": "createJiraIssue"},
{"name": "editJiraIssue"},
{"name": "transitionJiraIssue"},
],
}

View file

@ -11,4 +11,27 @@ def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
_ = {**(dependencies or {}), **kwargs}
return {"allow": [], "ask": []}
return {
"allow": [
{"name": "list_issues"},
{"name": "get_issue"},
{"name": "list_my_issues"},
{"name": "list_issue_statuses"},
{"name": "list_issue_labels"},
{"name": "list_comments"},
{"name": "list_users"},
{"name": "get_user"},
{"name": "list_teams"},
{"name": "get_team"},
{"name": "list_projects"},
{"name": "get_project"},
{"name": "list_project_labels"},
{"name": "list_cycles"},
{"name": "list_documents"},
{"name": "get_document"},
{"name": "search_documentation"},
],
"ask": [
{"name": "save_issue"}
],
}

View file

@ -11,4 +11,15 @@ def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> ToolsPermissions:
_ = {**(dependencies or {}), **kwargs}
return {"allow": [], "ask": []}
return {
"allow": [
{"name": "slack_search_channels"},
{"name": "slack_search_messages"},
{"name": "slack_search_users"},
{"name": "slack_read_channel"},
{"name": "slack_read_thread"},
],
"ask": [
{"name": "slack_send_message"},
],
}

View file

@ -1,11 +1,7 @@
"""Load MCP tools, partition by connector agent, apply allow/ask name rules."""
"""Load MCP tools, partition by connector agent, apply each subagent's allow/ask permissions."""
from __future__ import annotations
from app.agents.multi_agent_chat.subagents.mcp_tools.permissions import (
TOOLS_PERMISSIONS_BY_AGENT,
)
from .index import (
fetch_mcp_connector_metadata_maps,
load_mcp_tools_by_connector,
@ -13,7 +9,6 @@ from .index import (
)
__all__ = [
"TOOLS_PERMISSIONS_BY_AGENT",
"fetch_mcp_connector_metadata_maps",
"load_mcp_tools_by_connector",
"partition_mcp_tools_by_connector",

View file

@ -1,4 +1,4 @@
"""Discover MCP tools, bucket by connector agent, apply allow/ask from policy."""
"""Discover MCP tools, bucket by connector agent, apply each subagent's allow/ask permissions."""
from __future__ import annotations
@ -15,8 +15,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.multi_agent_chat.constants import (
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS,
)
from app.agents.multi_agent_chat.subagents.mcp_tools.permissions import (
TOOLS_PERMISSIONS_BY_AGENT,
from app.agents.multi_agent_chat.subagents.connectors.airtable.tools.index import (
load_tools as _airtable_permissions,
)
from app.agents.multi_agent_chat.subagents.connectors.clickup.tools.index import (
load_tools as _clickup_permissions,
)
from app.agents.multi_agent_chat.subagents.connectors.jira.tools.index import (
load_tools as _jira_permissions,
)
from app.agents.multi_agent_chat.subagents.connectors.linear.tools.index import (
load_tools as _linear_permissions,
)
from app.agents.multi_agent_chat.subagents.connectors.slack.tools.index import (
load_tools as _slack_permissions,
)
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.middleware_gated import (
middleware_gated_tool_permission_row,
@ -31,6 +43,15 @@ from app.db import SearchSourceConnector
logger = logging.getLogger(__name__)
_MCP_PERMISSIONS_BY_AGENT: dict[str, ToolsPermissions] = {
"airtable": _airtable_permissions(),
"clickup": _clickup_permissions(),
"jira": _jira_permissions(),
"linear": _linear_permissions(),
"slack": _slack_permissions(),
}
## Helper functions for fetching connector metadata maps
@ -150,7 +171,7 @@ async def load_mcp_tools_by_connector(
session: AsyncSession,
search_space_id: int,
) -> dict[str, ToolsPermissions]:
"""Load MCP tools and split rows using ``TOOLS_PERMISSIONS_BY_AGENT`` name sets.
"""Load MCP tools and split rows per subagent's own allow/ask permissions.
Pass ``bypass_internal_hitl=True`` so the subagent's
``HumanInTheLoopMiddleware`` is the single HITL gate.
@ -161,7 +182,7 @@ async def load_mcp_tools_by_connector(
return {
agent: _split_tools_by_permissions(
tools,
TOOLS_PERMISSIONS_BY_AGENT.get(agent, {"allow": [], "ask": []}),
_MCP_PERMISSIONS_BY_AGENT.get(agent, {"allow": [], "ask": []}),
)
for agent, tools in buckets.items()
}

View file

@ -1,23 +0,0 @@
"""Bundled MCP allow/ask name rows per connector agent (MCP-backed routes only)."""
from __future__ import annotations
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import (
ToolsPermissions,
)
from .airtable import TOOLS_PERMISSIONS as _AIRTABLE
from .clickup import TOOLS_PERMISSIONS as _CLICKUP
from .jira import TOOLS_PERMISSIONS as _JIRA
from .linear import TOOLS_PERMISSIONS as _LINEAR
from .slack import TOOLS_PERMISSIONS as _SLACK
TOOLS_PERMISSIONS_BY_AGENT: dict[str, ToolsPermissions] = {
"airtable": _AIRTABLE,
"clickup": _CLICKUP,
"jira": _JIRA,
"linear": _LINEAR,
"slack": _SLACK,
}
__all__ = ["TOOLS_PERMISSIONS_BY_AGENT"]

View file

@ -1,22 +0,0 @@
"""Airtable MCP: which server tool names are allow vs ask."""
from __future__ import annotations
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import (
ToolsPermissions,
)
TOOLS_PERMISSIONS: ToolsPermissions = {
"allow": [
{"name": "list_bases"},
{"name": "search_bases"},
{"name": "list_tables_for_base"},
{"name": "get_table_schema"},
{"name": "list_records_for_table"},
{"name": "search_records"},
],
"ask": [
{"name": "create_records_for_table"},
{"name": "update_records_for_table"},
],
}

View file

@ -1,21 +0,0 @@
"""ClickUp MCP: which server tool names are allow vs ask."""
from __future__ import annotations
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import (
ToolsPermissions,
)
TOOLS_PERMISSIONS: ToolsPermissions = {
"allow": [
{"name": "clickup_search"},
{"name": "clickup_get_task"},
{"name": "clickup_get_workspace_hierarchy"},
{"name": "clickup_get_list"},
{"name": "clickup_find_member_by_name"},
],
"ask": [
{"name": "clickup_create_task"},
{"name": "clickup_update_task"},
],
}

View file

@ -1,10 +0,0 @@
"""Re-exports permission row types for MCP policy modules."""
from __future__ import annotations
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import (
ToolPermissionItem,
ToolsPermissions,
)
__all__ = ["ToolPermissionItem", "ToolsPermissions"]

View file

@ -1,25 +0,0 @@
"""Jira MCP: which server tool names are allow vs ask."""
from __future__ import annotations
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import (
ToolsPermissions,
)
TOOLS_PERMISSIONS: ToolsPermissions = {
"allow": [
{"name": "getAccessibleAtlassianResources"},
{"name": "getVisibleJiraProjects"},
{"name": "searchJiraIssuesUsingJql"},
{"name": "getJiraIssue"},
{"name": "getJiraProjectIssueTypesMetadata"},
{"name": "getJiraIssueTypeMetaWithFields"},
{"name": "getTransitionsForJiraIssue"},
{"name": "lookupJiraAccountId"},
],
"ask": [
{"name": "createJiraIssue"},
{"name": "editJiraIssue"},
{"name": "transitionJiraIssue"},
],
}

View file

@ -1,32 +0,0 @@
"""Linear MCP: which server tool names are allow vs ask."""
from __future__ import annotations
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import (
ToolsPermissions,
)
_TOOLS_ALLOW = (
"list_issues",
"get_issue",
"list_my_issues",
"list_issue_statuses",
"list_issue_labels",
"list_comments",
"list_users",
"get_user",
"list_teams",
"get_team",
"list_projects",
"get_project",
"list_project_labels",
"list_cycles",
"list_documents",
"get_document",
"search_documentation",
)
TOOLS_PERMISSIONS: ToolsPermissions = {
"allow": [{"name": n} for n in _TOOLS_ALLOW],
"ask": [{"name": "save_issue"}],
}

View file

@ -1,20 +0,0 @@
"""Slack MCP: which server tool names are allow vs ask."""
from __future__ import annotations
from app.agents.multi_agent_chat.subagents.shared.tool_kinds import (
ToolsPermissions,
)
TOOLS_PERMISSIONS: ToolsPermissions = {
"allow": [
{"name": "slack_search_channels"},
{"name": "slack_search_messages"},
{"name": "slack_search_users"},
{"name": "slack_read_channel"},
{"name": "slack_read_thread"},
],
"ask": [
{"name": "slack_send_message"},
],
}

View file

@ -1,5 +1,4 @@
"""SurfSense's subagent contribution: deepagents spec + permission policy.
"""
"""SurfSense's subagent contribution: deepagents spec + permission ruleset."""
from __future__ import annotations
@ -19,7 +18,7 @@ class SurfSenseSubagentSpec:
only fields ``deepagents.SubAgent`` recognises.
ruleset: Permission rules this subagent contributes. The orchestrator
layers them into the subagent's :class:`PermissionMiddleware`,
so each subagent owns its own policy without aliasing the
so each subagent owns its own ruleset without aliasing the
shared rule engine.
"""

View file

@ -31,7 +31,7 @@ class ToolPermissionItem(TypedDict):
"""One allow/ask row.
``name`` is always set; ``tool`` is present when a bound BaseTool exists
(absent for name-only MCP policy rows). ``kind`` defaults to
(absent for name-only MCP allow/ask rows). ``kind`` defaults to
``self_gated`` when absent so existing connector factories keep working
without explicit tagging.
"""