From 8fca2753aab72fdc6e9398bfba66359c96a045de Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 4 Jun 2026 12:38:30 +0200 Subject: [PATCH] refactor(agents): move permissions to app/agents/shared (slice 4a) Relocate the permission evaluator (wildcard matcher + rule evaluation) to the shared kernel and flip 43 non-frozen importers. A re-export shim remains at new_chat/permissions.py for the frozen single-agent stack (chat_deepagent and subagents/{config,providers/linear,providers/slack}); it will be removed when that stack is retired. --- .../shared/permissions/ask/payload.py | 2 +- .../shared/permissions/ask/request.py | 2 +- .../middleware/shared/permissions/deny.py | 2 +- .../shared/permissions/middleware/core.py | 2 +- .../permissions/middleware/evaluation.py | 2 +- .../shared/permissions/middleware/factory.py | 2 +- .../permissions/middleware/ruleset_view.py | 2 +- .../permissions/middleware/runtime_promote.py | 2 +- .../builtins/deliverables/tools/index.py | 2 +- .../builtins/knowledge_base/agent.py | 2 +- .../knowledge_base/middleware_stack.py | 2 +- .../subagents/builtins/memory/tools/index.py | 2 +- .../builtins/research/tools/index.py | 2 +- .../connectors/airtable/tools/index.py | 2 +- .../connectors/calendar/tools/index.py | 2 +- .../connectors/clickup/tools/index.py | 2 +- .../connectors/confluence/tools/index.py | 2 +- .../connectors/discord/tools/index.py | 2 +- .../connectors/dropbox/tools/index.py | 2 +- .../subagents/connectors/gmail/tools/index.py | 2 +- .../connectors/google_drive/tools/index.py | 2 +- .../subagents/connectors/jira/tools/index.py | 2 +- .../connectors/linear/tools/index.py | 2 +- .../subagents/connectors/luma/tools/index.py | 2 +- .../connectors/notion/tools/index.py | 2 +- .../connectors/onedrive/tools/index.py | 2 +- .../subagents/connectors/slack/tools/index.py | 2 +- .../subagents/connectors/teams/tools/index.py | 2 +- .../multi_agent_chat/subagents/shared/spec.py | 2 +- .../subagents/shared/subagent_builder.py | 2 +- .../agents/new_chat/middleware/permission.py | 2 +- .../app/agents/new_chat/permissions.py | 202 ++--------------- .../app/agents/shared/permissions.py | 203 ++++++++++++++++++ .../app/services/user_tool_allowlist.py | 2 +- ...test_parallel_self_and_middleware_gated.py | 2 +- .../shared/permissions/test_lc_hitl_wire.py | 2 +- .../test_permission_ask_mcp_context.py | 2 +- .../test_subagent_owned_ruleset.py | 2 +- .../test_trusted_tool_save_on_always.py | 2 +- .../subagents/shared/test_subagent_builder.py | 2 +- .../test_default_permissions_layering.py | 2 +- .../new_chat/test_desktop_safety_rules.py | 2 +- .../new_chat/test_permission_middleware.py | 2 +- .../unit/agents/new_chat/test_permissions.py | 2 +- .../new_chat/test_specialized_subagents.py | 2 +- 45 files changed, 260 insertions(+), 231 deletions(-) create mode 100644 surfsense_backend/app/agents/shared/permissions.py diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py index 6c5d011df..dd9217e80 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/payload.py @@ -13,7 +13,7 @@ from app.agents.multi_agent_chat.subagents.shared.hitl.wire import ( SURFSENSE_DECISION_APPROVE_ALWAYS, build_lc_hitl_payload, ) -from app.agents.new_chat.permissions import Rule +from app.agents.shared.permissions import Rule PERMISSION_ASK_INTERRUPT_TYPE = "permission_ask" diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py index 3db51883d..c3c5ddd7f 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/ask/request.py @@ -16,7 +16,7 @@ from typing import Any from langchain_core.tools import BaseTool from langgraph.types import interrupt -from app.agents.new_chat.permissions import Rule +from app.agents.shared.permissions import Rule from app.observability import metrics as ot_metrics, otel as ot from .decision import normalize_permission_decision diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/deny.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/deny.py index ed5c872b3..05f7236cf 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/deny.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/deny.py @@ -12,7 +12,7 @@ from typing import Any from langchain_core.messages import ToolMessage from app.agents.shared.errors import StreamingError -from app.agents.new_chat.permissions import Rule +from app.agents.shared.permissions import Rule def build_deny_message(tool_call: dict[str, Any], rule: Rule) -> ToolMessage: diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py index 0bf93189a..06bf756ef 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/core.py @@ -27,7 +27,7 @@ from langchain_core.tools import BaseTool from langgraph.runtime import Runtime from app.agents.shared.errors import CorrectedError, RejectedError -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from app.services.user_tool_allowlist import TrustedToolSaver from ..ask.edit import merge_edited_args diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py index 51531c4eb..138bf810d 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/evaluation.py @@ -16,7 +16,7 @@ from __future__ import annotations import logging from typing import Any -from app.agents.new_chat.permissions import ( +from app.agents.shared.permissions import ( Rule, RuleAction, Ruleset, 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 ed42c5822..a115ad1f7 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 @@ -28,7 +28,7 @@ from collections.abc import Sequence from langchain_core.tools import BaseTool from app.agents.shared.feature_flags import AgentFeatureFlags -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset from app.services.user_tool_allowlist import TrustedToolSaver from .core import PermissionMiddleware diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/ruleset_view.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/ruleset_view.py index fbb66d455..210574243 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/ruleset_view.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/ruleset_view.py @@ -9,7 +9,7 @@ newly-promoted rules apply to subsequent calls. from __future__ import annotations -from app.agents.new_chat.permissions import Ruleset, aggregate_action, evaluate_many +from app.agents.shared.permissions import Ruleset, aggregate_action, evaluate_many def all_rulesets( diff --git a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/runtime_promote.py b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/runtime_promote.py index afc65fdc0..df9220241 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/runtime_promote.py +++ b/surfsense_backend/app/agents/multi_agent_chat/middleware/shared/permissions/middleware/runtime_promote.py @@ -7,7 +7,7 @@ is the streaming layer's job — this module keeps the in-memory copy only. from __future__ import annotations -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset def persist_always( diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py index ddfcbd7fb..60d711ab8 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/deliverables/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .generate_image import create_generate_image_tool from .podcast import create_generate_podcast_tool 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 f08cea5fa..a6e99bb08 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 @@ -15,7 +15,7 @@ from langchain_core.tools import BaseTool from app.agents.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec from app.agents.shared.filesystem_selection import FilesystemMode -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset from .middleware_stack import build_kb_middleware from .prompts import load_description, load_readonly_system_prompt, load_system_prompt 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 04d5c1376..4251b8b14 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 @@ -30,7 +30,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions import ( ) from app.agents.shared.feature_flags import AgentFeatureFlags from app.agents.shared.filesystem_selection import FilesystemMode -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset def _kb_user_allowlist( diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py index b6e06dcdd..e610db79b 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/memory/tools/index.py @@ -6,7 +6,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from app.db import ChatVisibility from .update_memory import create_update_memory_tool, create_update_team_memory_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py index d8abce46c..bf99c2433 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/research/tools/index.py @@ -6,7 +6,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .scrape_webpage import create_scrape_webpage_tool from .web_search import create_web_search_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py index 9eebd2395..ebf71a640 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/airtable/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset NAME = "airtable" diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py index 2570a51b2..251f05c9a 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/calendar/tools/index.py @@ -10,7 +10,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .create_event import create_create_calendar_event_tool from .delete_event import create_delete_calendar_event_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py index b2c523080..6d5a3dca2 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/clickup/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset NAME = "clickup" diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py index b38503c5c..cbe8f3274 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/confluence/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .create_page import create_create_confluence_page_tool from .delete_page import create_delete_confluence_page_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py index c69ef3e5c..dfb4754ee 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/discord/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .list_channels import create_list_discord_channels_tool from .read_messages import create_read_discord_messages_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py index 68e02866a..30dd835e0 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/dropbox/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .create_file import create_create_dropbox_file_tool from .trash_file import create_delete_dropbox_file_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py index 020089ebb..3097287e5 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/gmail/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .create_draft import create_create_gmail_draft_tool from .read_email import create_read_gmail_email_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py index dd05374a1..95b78d53c 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/google_drive/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .create_file import create_create_google_drive_file_tool from .trash_file import create_delete_google_drive_file_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py index 24f1bdc01..80b6c01ce 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/jira/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset NAME = "jira" diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py index 4a71a31b8..5654e426f 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/linear/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset NAME = "linear" diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py index dbde01061..9b6dfbc77 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/luma/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .create_event import create_create_luma_event_tool from .list_events import create_list_luma_events_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py index 0475e9dd0..b24ed6089 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/notion/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .create_page import create_create_notion_page_tool from .delete_page import create_delete_notion_page_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py index e09b43200..396523cac 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/onedrive/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .create_file import create_create_onedrive_file_tool from .trash_file import create_delete_onedrive_file_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py index 44b96661c..2e4786b9f 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/slack/tools/index.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset NAME = "slack" diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py index 41661651f..8879106a6 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/connectors/teams/tools/index.py @@ -9,7 +9,7 @@ from typing import Any from langchain_core.tools import BaseTool -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset from .list_channels import create_list_teams_channels_tool from .read_messages import create_read_teams_messages_tool diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/spec.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/spec.py index f891f94d2..310ddd6ad 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/spec.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/spec.py @@ -8,7 +8,7 @@ from typing import Any from deepagents import SubAgent -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset # A context-hint provider receives the parent-agent ``runtime.state`` mapping # and the ``description`` the orchestrator wrote, and returns a short string 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 5025b32e7..46f2d555d 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 @@ -22,7 +22,7 @@ from app.agents.multi_agent_chat.subagents.shared.spec import ( ContextHintProvider, SurfSenseSubagentSpec, ) -from app.agents.new_chat.permissions import Ruleset +from app.agents.shared.permissions import Ruleset logger = logging.getLogger(__name__) diff --git a/surfsense_backend/app/agents/new_chat/middleware/permission.py b/surfsense_backend/app/agents/new_chat/middleware/permission.py index 8545b69c9..8601a3296 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/permission.py +++ b/surfsense_backend/app/agents/new_chat/middleware/permission.py @@ -55,7 +55,7 @@ from app.agents.shared.errors import ( RejectedError, StreamingError, ) -from app.agents.new_chat.permissions import ( +from app.agents.shared.permissions import ( Rule, Ruleset, aggregate_action, diff --git a/surfsense_backend/app/agents/new_chat/permissions.py b/surfsense_backend/app/agents/new_chat/permissions.py index 523deb11f..49ded62ce 100644 --- a/surfsense_backend/app/agents/new_chat/permissions.py +++ b/surfsense_backend/app/agents/new_chat/permissions.py @@ -1,196 +1,22 @@ -""" -Wildcard pattern matching + rule evaluation for the SurfSense permission system. +"""Backward-compatible shim. -Ported from OpenCode's ``packages/opencode/src/permission/evaluate.ts`` and -``packages/opencode/src/util/wildcard.ts``. LangChain has no rule-based -permission evaluator, so we keep OpenCode's semantics intact: - -- ``Wildcard.match`` matches both the ``permission`` and the ``pattern`` - fields of a rule against the requested ``(permission, pattern)`` pair. - ``*`` matches any segment, ``**`` matches across separators. -- The evaluator runs ``findLast`` over the **flattened** list of rules - from all rulesets — last matching rule wins. -- The default fallback is ``ask`` (NOT deny), matching OpenCode. -- Multi-pattern requests AND together: if ANY pattern resolves to - ``deny``, the whole request is denied; if ANY needs ``ask``, an - interrupt is raised; only when all patterns ``allow`` does the - request proceed. +The permission evaluator now lives in the shared agent kernel at +``app.agents.shared.permissions``. This module re-exports it so frozen +single-agent code (``chat_deepagent`` and ``subagents/*``) keeps working +until that stack is retired. """ from __future__ import annotations -import re -from collections.abc import Iterable -from dataclasses import dataclass, field -from typing import Literal - -RuleAction = Literal["allow", "deny", "ask"] - - -@dataclass(frozen=True) -class Rule: - """A single permission rule. - - Attributes: - permission: A wildcard-matched permission identifier - (e.g. ``"edit"``, ``"linear_*"``, ``"mcp:*"``, - ``"doom_loop"``). Anchored at start AND end of the input. - pattern: A wildcard-matched pattern over the request payload - (e.g. ``"/documents/secrets/**"``, ``"page_id=123"``, - ``"*"``). Anchored at start AND end. - action: One of ``"allow"`` / ``"deny"`` / ``"ask"``. - """ - - permission: str - pattern: str - action: RuleAction - - -@dataclass -class Ruleset: - """A list of rules with an associated origin used for debugging.""" - - rules: list[Rule] = field(default_factory=list) - origin: str = "unknown" # e.g. "defaults", "global", "space", "thread", "runtime" - - -# ----------------------------------------------------------------------------- -# Wildcard matcher -# ----------------------------------------------------------------------------- - - -_GLOB_TOKEN = re.compile(r"\*\*|\*|[^*]+") - - -def _wildcard_to_regex(pattern: str) -> re.Pattern[str]: - """Translate an opencode-style wildcard pattern to a compiled regex. - - Rules: - - ``**`` matches any sequence of any characters (including separators). - - ``*`` matches any sequence of characters that does **not** include - the path separator ``/`` — same as glob. - - All other characters match literally. - - The pattern is anchored at both ends (``^...$``). - """ - parts: list[str] = ["^"] - for token in _GLOB_TOKEN.findall(pattern): - if token == "**": - parts.append(r".*") - elif token == "*": - parts.append(r"[^/]*") - else: - parts.append(re.escape(token)) - parts.append("$") - return re.compile("".join(parts)) - - -_REGEX_CACHE: dict[str, re.Pattern[str]] = {} - - -def wildcard_match(value: str, pattern: str) -> bool: - """Return True if ``value`` matches the wildcard ``pattern``. - - Special case: a bare ``"*"`` pattern matches any value, including - those containing ``/`` separators. This mirrors opencode's - ``Wildcard.match`` short-circuit and matches the convention that - ``pattern="*"`` means "any pattern" in permission rules. - """ - if pattern == "*": - return True - compiled = _REGEX_CACHE.get(pattern) - if compiled is None: - compiled = _wildcard_to_regex(pattern) - _REGEX_CACHE[pattern] = compiled - return compiled.match(value) is not None - - -# ----------------------------------------------------------------------------- -# Evaluator -# ----------------------------------------------------------------------------- - - -def evaluate( - permission: str, - pattern: str, - *rulesets: Ruleset | Iterable[Rule], -) -> Rule: - """Find the last rule matching ``(permission, pattern)`` from ``rulesets``. - - Mirrors opencode ``permission/evaluate.ts:9-15`` precisely: - - Flatten rulesets in argument order. - - Walk the flat list **in reverse**. - - First reverse-match wins (i.e. the last specified rule wins). - - When no rule matches, default to ``Rule(permission, "*", "ask")``. - - Args: - permission: The permission identifier being requested - (e.g. tool name, ``"edit"``, ``"doom_loop"``). - pattern: The request-specific pattern (e.g. file path, - primary arg value). Use ``"*"`` when no specific pattern - applies. - *rulesets: Layered rulesets, applied earliest to latest. Later - rulesets override earlier ones. - - Returns: - The matched :class:`Rule`, or the default ask fallback. - """ - flat: list[Rule] = [] - for rs in rulesets: - if isinstance(rs, Ruleset): - flat.extend(rs.rules) - else: - flat.extend(rs) - - for rule in reversed(flat): - if wildcard_match(permission, rule.permission) and wildcard_match( - pattern, rule.pattern - ): - return rule - - return Rule(permission=permission, pattern="*", action="ask") - - -def evaluate_many( - permission: str, - patterns: Iterable[str], - *rulesets: Ruleset | Iterable[Rule], -) -> list[Rule]: - """Evaluate ``permission`` against each of ``patterns`` (multi-pattern AND). - - Returns the list of resolved rules in the same order as ``patterns``. - The caller is responsible for combining the results — opencode-style - multi-pattern AND collapses ``deny`` first, then ``ask``, then - ``allow``. - """ - return [evaluate(permission, p, *rulesets) for p in patterns] - - -def aggregate_action(rules: Iterable[Rule]) -> RuleAction: - """Collapse a list of per-pattern rules into one action. - - Order: - 1. If any rule is ``deny`` -> ``deny``. - 2. Else if any rule is ``ask`` -> ``ask``. - 3. Else if at least one rule is ``allow`` -> ``allow``. - 4. Else (empty input) -> ``ask`` (safe default mirroring ``evaluate``). - - Mirrors opencode's behavior in ``permission/index.ts:180-272``. - """ - saw_ask = False - saw_allow = False - for rule in rules: - if rule.action == "deny": - return "deny" - if rule.action == "ask": - saw_ask = True - elif rule.action == "allow": - saw_allow = True - if saw_ask: - return "ask" - if saw_allow: - return "allow" - return "ask" - +from app.agents.shared.permissions import ( + Rule, + RuleAction, + Ruleset, + aggregate_action, + evaluate, + evaluate_many, + wildcard_match, +) __all__ = [ "Rule", diff --git a/surfsense_backend/app/agents/shared/permissions.py b/surfsense_backend/app/agents/shared/permissions.py new file mode 100644 index 000000000..523deb11f --- /dev/null +++ b/surfsense_backend/app/agents/shared/permissions.py @@ -0,0 +1,203 @@ +""" +Wildcard pattern matching + rule evaluation for the SurfSense permission system. + +Ported from OpenCode's ``packages/opencode/src/permission/evaluate.ts`` and +``packages/opencode/src/util/wildcard.ts``. LangChain has no rule-based +permission evaluator, so we keep OpenCode's semantics intact: + +- ``Wildcard.match`` matches both the ``permission`` and the ``pattern`` + fields of a rule against the requested ``(permission, pattern)`` pair. + ``*`` matches any segment, ``**`` matches across separators. +- The evaluator runs ``findLast`` over the **flattened** list of rules + from all rulesets — last matching rule wins. +- The default fallback is ``ask`` (NOT deny), matching OpenCode. +- Multi-pattern requests AND together: if ANY pattern resolves to + ``deny``, the whole request is denied; if ANY needs ``ask``, an + interrupt is raised; only when all patterns ``allow`` does the + request proceed. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Literal + +RuleAction = Literal["allow", "deny", "ask"] + + +@dataclass(frozen=True) +class Rule: + """A single permission rule. + + Attributes: + permission: A wildcard-matched permission identifier + (e.g. ``"edit"``, ``"linear_*"``, ``"mcp:*"``, + ``"doom_loop"``). Anchored at start AND end of the input. + pattern: A wildcard-matched pattern over the request payload + (e.g. ``"/documents/secrets/**"``, ``"page_id=123"``, + ``"*"``). Anchored at start AND end. + action: One of ``"allow"`` / ``"deny"`` / ``"ask"``. + """ + + permission: str + pattern: str + action: RuleAction + + +@dataclass +class Ruleset: + """A list of rules with an associated origin used for debugging.""" + + rules: list[Rule] = field(default_factory=list) + origin: str = "unknown" # e.g. "defaults", "global", "space", "thread", "runtime" + + +# ----------------------------------------------------------------------------- +# Wildcard matcher +# ----------------------------------------------------------------------------- + + +_GLOB_TOKEN = re.compile(r"\*\*|\*|[^*]+") + + +def _wildcard_to_regex(pattern: str) -> re.Pattern[str]: + """Translate an opencode-style wildcard pattern to a compiled regex. + + Rules: + - ``**`` matches any sequence of any characters (including separators). + - ``*`` matches any sequence of characters that does **not** include + the path separator ``/`` — same as glob. + - All other characters match literally. + - The pattern is anchored at both ends (``^...$``). + """ + parts: list[str] = ["^"] + for token in _GLOB_TOKEN.findall(pattern): + if token == "**": + parts.append(r".*") + elif token == "*": + parts.append(r"[^/]*") + else: + parts.append(re.escape(token)) + parts.append("$") + return re.compile("".join(parts)) + + +_REGEX_CACHE: dict[str, re.Pattern[str]] = {} + + +def wildcard_match(value: str, pattern: str) -> bool: + """Return True if ``value`` matches the wildcard ``pattern``. + + Special case: a bare ``"*"`` pattern matches any value, including + those containing ``/`` separators. This mirrors opencode's + ``Wildcard.match`` short-circuit and matches the convention that + ``pattern="*"`` means "any pattern" in permission rules. + """ + if pattern == "*": + return True + compiled = _REGEX_CACHE.get(pattern) + if compiled is None: + compiled = _wildcard_to_regex(pattern) + _REGEX_CACHE[pattern] = compiled + return compiled.match(value) is not None + + +# ----------------------------------------------------------------------------- +# Evaluator +# ----------------------------------------------------------------------------- + + +def evaluate( + permission: str, + pattern: str, + *rulesets: Ruleset | Iterable[Rule], +) -> Rule: + """Find the last rule matching ``(permission, pattern)`` from ``rulesets``. + + Mirrors opencode ``permission/evaluate.ts:9-15`` precisely: + - Flatten rulesets in argument order. + - Walk the flat list **in reverse**. + - First reverse-match wins (i.e. the last specified rule wins). + - When no rule matches, default to ``Rule(permission, "*", "ask")``. + + Args: + permission: The permission identifier being requested + (e.g. tool name, ``"edit"``, ``"doom_loop"``). + pattern: The request-specific pattern (e.g. file path, + primary arg value). Use ``"*"`` when no specific pattern + applies. + *rulesets: Layered rulesets, applied earliest to latest. Later + rulesets override earlier ones. + + Returns: + The matched :class:`Rule`, or the default ask fallback. + """ + flat: list[Rule] = [] + for rs in rulesets: + if isinstance(rs, Ruleset): + flat.extend(rs.rules) + else: + flat.extend(rs) + + for rule in reversed(flat): + if wildcard_match(permission, rule.permission) and wildcard_match( + pattern, rule.pattern + ): + return rule + + return Rule(permission=permission, pattern="*", action="ask") + + +def evaluate_many( + permission: str, + patterns: Iterable[str], + *rulesets: Ruleset | Iterable[Rule], +) -> list[Rule]: + """Evaluate ``permission`` against each of ``patterns`` (multi-pattern AND). + + Returns the list of resolved rules in the same order as ``patterns``. + The caller is responsible for combining the results — opencode-style + multi-pattern AND collapses ``deny`` first, then ``ask``, then + ``allow``. + """ + return [evaluate(permission, p, *rulesets) for p in patterns] + + +def aggregate_action(rules: Iterable[Rule]) -> RuleAction: + """Collapse a list of per-pattern rules into one action. + + Order: + 1. If any rule is ``deny`` -> ``deny``. + 2. Else if any rule is ``ask`` -> ``ask``. + 3. Else if at least one rule is ``allow`` -> ``allow``. + 4. Else (empty input) -> ``ask`` (safe default mirroring ``evaluate``). + + Mirrors opencode's behavior in ``permission/index.ts:180-272``. + """ + saw_ask = False + saw_allow = False + for rule in rules: + if rule.action == "deny": + return "deny" + if rule.action == "ask": + saw_ask = True + elif rule.action == "allow": + saw_allow = True + if saw_ask: + return "ask" + if saw_allow: + return "allow" + return "ask" + + +__all__ = [ + "Rule", + "RuleAction", + "Ruleset", + "aggregate_action", + "evaluate", + "evaluate_many", + "wildcard_match", +] diff --git a/surfsense_backend/app/services/user_tool_allowlist.py b/surfsense_backend/app/services/user_tool_allowlist.py index fdfa51560..525d7b0ef 100644 --- a/surfsense_backend/app/services/user_tool_allowlist.py +++ b/surfsense_backend/app/services/user_tool_allowlist.py @@ -19,7 +19,7 @@ from sqlalchemy.orm.attributes import flag_modified from app.agents.multi_agent_chat.constants import ( CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS, ) -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset from app.db import SearchSourceConnector, async_session_maker logger = logging.getLogger(__name__) diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_parallel_self_and_middleware_gated.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_parallel_self_and_middleware_gated.py index 921c4a9eb..26ba32e34 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_parallel_self_and_middleware_gated.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/checkpointed_subagent_middleware/test_parallel_self_and_middleware_gated.py @@ -49,7 +49,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions.ask.request impor from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import ( request_approval, ) -from app.agents.new_chat.permissions import Rule +from app.agents.shared.permissions import Rule class _SubState(TypedDict, total=False): diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_lc_hitl_wire.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_lc_hitl_wire.py index a331190b2..ad7ecf610 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_lc_hitl_wire.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_lc_hitl_wire.py @@ -19,7 +19,7 @@ from typing_extensions import TypedDict from app.agents.multi_agent_chat.middleware.shared.permissions.ask.request import ( request_permission_decision, ) -from app.agents.new_chat.permissions import Rule +from app.agents.shared.permissions import Rule class _State(TypedDict, total=False): diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_permission_ask_mcp_context.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_permission_ask_mcp_context.py index 1eaac5113..82b28d04a 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_permission_ask_mcp_context.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_permission_ask_mcp_context.py @@ -20,7 +20,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions.ask.payload impor build_permission_ask_payload, ) from app.agents.shared.feature_flags import AgentFeatureFlags -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset class _NoArgs(BaseModel): 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 66dc5d76f..43d769f6d 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 @@ -27,7 +27,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions import ( build_permission_mw, ) from app.agents.shared.feature_flags import AgentFeatureFlags -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset def _kb_style_ruleset() -> Ruleset: diff --git a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_trusted_tool_save_on_always.py b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_trusted_tool_save_on_always.py index e3493b9bb..cd2789b56 100644 --- a/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_trusted_tool_save_on_always.py +++ b/surfsense_backend/tests/unit/agents/multi_agent_chat/middleware/shared/permissions/test_trusted_tool_save_on_always.py @@ -18,7 +18,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions import ( build_permission_mw, ) from app.agents.shared.feature_flags import AgentFeatureFlags -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset class _NoArgs(BaseModel): 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 e65cffe47..34aa0515a 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 @@ -26,7 +26,7 @@ from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( pack_subagent, ) from app.agents.shared.feature_flags import AgentFeatureFlags -from app.agents.new_chat.permissions import Rule, Ruleset, evaluate +from app.agents.shared.permissions import Rule, Ruleset, evaluate class RateLimitError(Exception): diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py b/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py index 2f222e148..796df8128 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_default_permissions_layering.py @@ -27,7 +27,7 @@ from __future__ import annotations import pytest -from app.agents.new_chat.permissions import ( +from app.agents.shared.permissions import ( Rule, Ruleset, aggregate_action, diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_desktop_safety_rules.py b/surfsense_backend/tests/unit/agents/new_chat/test_desktop_safety_rules.py index d7b410aa6..b513d68d8 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_desktop_safety_rules.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_desktop_safety_rules.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest from app.agents.new_chat.middleware.permission import PermissionMiddleware -from app.agents.new_chat.permissions import ( +from app.agents.shared.permissions import ( Rule, Ruleset, aggregate_action, diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py b/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py index 146e31763..faf27328f 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_permission_middleware.py @@ -10,7 +10,7 @@ from app.agents.new_chat.middleware.permission import ( PermissionMiddleware, _normalize_permission_decision, ) -from app.agents.new_chat.permissions import Rule, Ruleset +from app.agents.shared.permissions import Rule, Ruleset pytestmark = pytest.mark.unit diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_permissions.py b/surfsense_backend/tests/unit/agents/new_chat/test_permissions.py index 8ec16617a..37d0e906a 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_permissions.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_permissions.py @@ -4,7 +4,7 @@ from __future__ import annotations import pytest -from app.agents.new_chat.permissions import ( +from app.agents.shared.permissions import ( Rule, Ruleset, aggregate_action, diff --git a/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py b/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py index 3c7fe5336..7259c49f8 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py +++ b/surfsense_backend/tests/unit/agents/new_chat/test_specialized_subagents.py @@ -332,6 +332,6 @@ class TestDenyPatternsCoverage: def _wildcard_matches(pattern: str, value: str) -> bool: """Helper using the same matcher the rule evaluator does.""" - from app.agents.new_chat.permissions import wildcard_match + from app.agents.shared.permissions import wildcard_match return wildcard_match(value, pattern)