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:
CREDO23 2026-06-04 12:38:30 +02:00
parent 3efe51e6ec
commit 8fca2753aa
45 changed files with 260 additions and 231 deletions

View file

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

View file

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

View file

@ -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:

View file

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

View file

@ -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,

View file

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

View file

@ -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(

View file

@ -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(

View file

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

View file

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

View file

@ -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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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__)

View file

@ -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,

View file

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

View 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",
]

View file

@ -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__)

View file

@ -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):

View file

@ -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):

View file

@ -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):

View file

@ -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:

View file

@ -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):

View file

@ -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):

View file

@ -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,

View file

@ -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,

View file

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

View file

@ -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,

View file

@ -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)