mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
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.
This commit is contained in:
parent
3efe51e6ec
commit
8fca2753aa
45 changed files with 260 additions and 231 deletions
|
|
@ -13,7 +13,7 @@ from app.agents.multi_agent_chat.subagents.shared.hitl.wire import (
|
||||||
SURFSENSE_DECISION_APPROVE_ALWAYS,
|
SURFSENSE_DECISION_APPROVE_ALWAYS,
|
||||||
build_lc_hitl_payload,
|
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"
|
PERMISSION_ASK_INTERRUPT_TYPE = "permission_ask"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from typing import Any
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
from langgraph.types import interrupt
|
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 app.observability import metrics as ot_metrics, otel as ot
|
||||||
|
|
||||||
from .decision import normalize_permission_decision
|
from .decision import normalize_permission_decision
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from typing import Any
|
||||||
from langchain_core.messages import ToolMessage
|
from langchain_core.messages import ToolMessage
|
||||||
|
|
||||||
from app.agents.shared.errors import StreamingError
|
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:
|
def build_deny_message(tool_call: dict[str, Any], rule: Rule) -> ToolMessage:
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from langchain_core.tools import BaseTool
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
from app.agents.shared.errors import CorrectedError, RejectedError
|
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 app.services.user_tool_allowlist import TrustedToolSaver
|
||||||
|
|
||||||
from ..ask.edit import merge_edited_args
|
from ..ask.edit import merge_edited_args
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.agents.new_chat.permissions import (
|
from app.agents.shared.permissions import (
|
||||||
Rule,
|
Rule,
|
||||||
RuleAction,
|
RuleAction,
|
||||||
Ruleset,
|
Ruleset,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ from collections.abc import Sequence
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
|
|
||||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
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 app.services.user_tool_allowlist import TrustedToolSaver
|
||||||
|
|
||||||
from .core import PermissionMiddleware
|
from .core import PermissionMiddleware
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ newly-promoted rules apply to subsequent calls.
|
||||||
|
|
||||||
from __future__ import annotations
|
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(
|
def all_rulesets(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ is the streaming layer's job — this module keeps the in-memory copy only.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.agents.new_chat.permissions import Rule, Ruleset
|
from app.agents.shared.permissions import Rule, Ruleset
|
||||||
|
|
||||||
|
|
||||||
def persist_always(
|
def persist_always(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .generate_image import create_generate_image_tool
|
||||||
from .podcast import create_generate_podcast_tool
|
from .podcast import create_generate_podcast_tool
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from langchain_core.tools import BaseTool
|
||||||
|
|
||||||
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.shared.filesystem_selection import FilesystemMode
|
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 .middleware_stack import build_kb_middleware
|
||||||
from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
|
from .prompts import load_description, load_readonly_system_prompt, load_system_prompt
|
||||||
|
|
|
||||||
|
|
@ -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.feature_flags import AgentFeatureFlags
|
||||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
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(
|
def _kb_user_allowlist(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 app.db import ChatVisibility
|
||||||
|
|
||||||
from .update_memory import create_update_memory_tool, create_update_team_memory_tool
|
from .update_memory import create_update_memory_tool, create_update_team_memory_tool
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .scrape_webpage import create_scrape_webpage_tool
|
||||||
from .web_search import create_web_search_tool
|
from .web_search import create_web_search_tool
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.agents.new_chat.permissions import Rule, Ruleset
|
from app.agents.shared.permissions import Rule, Ruleset
|
||||||
|
|
||||||
NAME = "airtable"
|
NAME = "airtable"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .create_event import create_create_calendar_event_tool
|
||||||
from .delete_event import create_delete_calendar_event_tool
|
from .delete_event import create_delete_calendar_event_tool
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.agents.new_chat.permissions import Rule, Ruleset
|
from app.agents.shared.permissions import Rule, Ruleset
|
||||||
|
|
||||||
NAME = "clickup"
|
NAME = "clickup"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .create_page import create_create_confluence_page_tool
|
||||||
from .delete_page import create_delete_confluence_page_tool
|
from .delete_page import create_delete_confluence_page_tool
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .list_channels import create_list_discord_channels_tool
|
||||||
from .read_messages import create_read_discord_messages_tool
|
from .read_messages import create_read_discord_messages_tool
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .create_file import create_create_dropbox_file_tool
|
||||||
from .trash_file import create_delete_dropbox_file_tool
|
from .trash_file import create_delete_dropbox_file_tool
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .create_draft import create_create_gmail_draft_tool
|
||||||
from .read_email import create_read_gmail_email_tool
|
from .read_email import create_read_gmail_email_tool
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .create_file import create_create_google_drive_file_tool
|
||||||
from .trash_file import create_delete_google_drive_file_tool
|
from .trash_file import create_delete_google_drive_file_tool
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.agents.new_chat.permissions import Rule, Ruleset
|
from app.agents.shared.permissions import Rule, Ruleset
|
||||||
|
|
||||||
NAME = "jira"
|
NAME = "jira"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.agents.new_chat.permissions import Rule, Ruleset
|
from app.agents.shared.permissions import Rule, Ruleset
|
||||||
|
|
||||||
NAME = "linear"
|
NAME = "linear"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .create_event import create_create_luma_event_tool
|
||||||
from .list_events import create_list_luma_events_tool
|
from .list_events import create_list_luma_events_tool
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .create_page import create_create_notion_page_tool
|
||||||
from .delete_page import create_delete_notion_page_tool
|
from .delete_page import create_delete_notion_page_tool
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .create_file import create_create_onedrive_file_tool
|
||||||
from .trash_file import create_delete_onedrive_file_tool
|
from .trash_file import create_delete_onedrive_file_tool
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.agents.new_chat.permissions import Rule, Ruleset
|
from app.agents.shared.permissions import Rule, Ruleset
|
||||||
|
|
||||||
NAME = "slack"
|
NAME = "slack"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
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 .list_channels import create_list_teams_channels_tool
|
||||||
from .read_messages import create_read_teams_messages_tool
|
from .read_messages import create_read_teams_messages_tool
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Any
|
||||||
|
|
||||||
from deepagents import SubAgent
|
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
|
# A context-hint provider receives the parent-agent ``runtime.state`` mapping
|
||||||
# and the ``description`` the orchestrator wrote, and returns a short string
|
# and the ``description`` the orchestrator wrote, and returns a short string
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ from app.agents.multi_agent_chat.subagents.shared.spec import (
|
||||||
ContextHintProvider,
|
ContextHintProvider,
|
||||||
SurfSenseSubagentSpec,
|
SurfSenseSubagentSpec,
|
||||||
)
|
)
|
||||||
from app.agents.new_chat.permissions import Ruleset
|
from app.agents.shared.permissions import Ruleset
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ from app.agents.shared.errors import (
|
||||||
RejectedError,
|
RejectedError,
|
||||||
StreamingError,
|
StreamingError,
|
||||||
)
|
)
|
||||||
from app.agents.new_chat.permissions import (
|
from app.agents.shared.permissions import (
|
||||||
Rule,
|
Rule,
|
||||||
Ruleset,
|
Ruleset,
|
||||||
aggregate_action,
|
aggregate_action,
|
||||||
|
|
|
||||||
|
|
@ -1,196 +1,22 @@
|
||||||
"""
|
"""Backward-compatible shim.
|
||||||
Wildcard pattern matching + rule evaluation for the SurfSense permission system.
|
|
||||||
|
|
||||||
Ported from OpenCode's ``packages/opencode/src/permission/evaluate.ts`` and
|
The permission evaluator now lives in the shared agent kernel at
|
||||||
``packages/opencode/src/util/wildcard.ts``. LangChain has no rule-based
|
``app.agents.shared.permissions``. This module re-exports it so frozen
|
||||||
permission evaluator, so we keep OpenCode's semantics intact:
|
single-agent code (``chat_deepagent`` and ``subagents/*``) keeps working
|
||||||
|
until that stack is retired.
|
||||||
- ``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
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
from app.agents.shared.permissions import (
|
||||||
from collections.abc import Iterable
|
Rule,
|
||||||
from dataclasses import dataclass, field
|
RuleAction,
|
||||||
from typing import Literal
|
Ruleset,
|
||||||
|
aggregate_action,
|
||||||
RuleAction = Literal["allow", "deny", "ask"]
|
evaluate,
|
||||||
|
evaluate_many,
|
||||||
|
wildcard_match,
|
||||||
@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__ = [
|
__all__ = [
|
||||||
"Rule",
|
"Rule",
|
||||||
|
|
|
||||||
203
surfsense_backend/app/agents/shared/permissions.py
Normal file
203
surfsense_backend/app/agents/shared/permissions.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -19,7 +19,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||||
from app.agents.multi_agent_chat.constants import (
|
from app.agents.multi_agent_chat.constants import (
|
||||||
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS,
|
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
|
from app.db import SearchSourceConnector, async_session_maker
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
|
||||||
request_approval,
|
request_approval,
|
||||||
)
|
)
|
||||||
from app.agents.new_chat.permissions import Rule
|
from app.agents.shared.permissions import Rule
|
||||||
|
|
||||||
|
|
||||||
class _SubState(TypedDict, total=False):
|
class _SubState(TypedDict, total=False):
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from typing_extensions import TypedDict
|
||||||
from app.agents.multi_agent_chat.middleware.shared.permissions.ask.request import (
|
from app.agents.multi_agent_chat.middleware.shared.permissions.ask.request import (
|
||||||
request_permission_decision,
|
request_permission_decision,
|
||||||
)
|
)
|
||||||
from app.agents.new_chat.permissions import Rule
|
from app.agents.shared.permissions import Rule
|
||||||
|
|
||||||
|
|
||||||
class _State(TypedDict, total=False):
|
class _State(TypedDict, total=False):
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions.ask.payload impor
|
||||||
build_permission_ask_payload,
|
build_permission_ask_payload,
|
||||||
)
|
)
|
||||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
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):
|
class _NoArgs(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions import (
|
||||||
build_permission_mw,
|
build_permission_mw,
|
||||||
)
|
)
|
||||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
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:
|
def _kb_style_ruleset() -> Ruleset:
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions import (
|
||||||
build_permission_mw,
|
build_permission_mw,
|
||||||
)
|
)
|
||||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
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):
|
class _NoArgs(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
|
||||||
pack_subagent,
|
pack_subagent,
|
||||||
)
|
)
|
||||||
from app.agents.shared.feature_flags import AgentFeatureFlags
|
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):
|
class RateLimitError(Exception):
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.agents.new_chat.permissions import (
|
from app.agents.shared.permissions import (
|
||||||
Rule,
|
Rule,
|
||||||
Ruleset,
|
Ruleset,
|
||||||
aggregate_action,
|
aggregate_action,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.agents.new_chat.middleware.permission import PermissionMiddleware
|
from app.agents.new_chat.middleware.permission import PermissionMiddleware
|
||||||
from app.agents.new_chat.permissions import (
|
from app.agents.shared.permissions import (
|
||||||
Rule,
|
Rule,
|
||||||
Ruleset,
|
Ruleset,
|
||||||
aggregate_action,
|
aggregate_action,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from app.agents.new_chat.middleware.permission import (
|
||||||
PermissionMiddleware,
|
PermissionMiddleware,
|
||||||
_normalize_permission_decision,
|
_normalize_permission_decision,
|
||||||
)
|
)
|
||||||
from app.agents.new_chat.permissions import Rule, Ruleset
|
from app.agents.shared.permissions import Rule, Ruleset
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.agents.new_chat.permissions import (
|
from app.agents.shared.permissions import (
|
||||||
Rule,
|
Rule,
|
||||||
Ruleset,
|
Ruleset,
|
||||||
aggregate_action,
|
aggregate_action,
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,6 @@ class TestDenyPatternsCoverage:
|
||||||
|
|
||||||
def _wildcard_matches(pattern: str, value: str) -> bool:
|
def _wildcard_matches(pattern: str, value: str) -> bool:
|
||||||
"""Helper using the same matcher the rule evaluator does."""
|
"""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)
|
return wildcard_match(value, pattern)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue