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
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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue