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

@ -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.
"""