mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-19 18:45:15 +02:00
multi_agent_chat/permissions: persist 'always' decisions to trusted-tools list
Until now an "Always Allow" reply only updated the in-memory runtime ruleset, evaporating after the session ended. Persist it to the existing connector.config['trusted_tools'] list so the next session's fetch_user_allowlist_rulesets picks it up and the user is never asked again for the same (connector, tool) pair. - TrustedToolSaver + make_trusted_tool_saver(user_id) in user_tool_allowlist: opens its own session via async_session_maker per call, logs and swallows failures (in-memory promotion is the canonical "always" path, durable persistence is opportunistic). - PermissionMiddleware._process is now pure: returns (state_update, list[_AlwaysPromotion]). aafter_model awaits the saver for each promotion; after_model discards them. Promotions are only emitted for tools whose metadata exposes mcp_connector_id, so native tools and KB FS ops are correctly skipped. - main_agent factory builds the saver once per turn and stashes it in dependencies["trusted_tool_saver"]; pack_subagent and the KB middleware stack forward it through build_permission_mw. - Renamed pm._process(state, None) call sites in two existing tests to pm.after_model(state, None) so they exercise the public hook contract instead of the now-tuple-returning private method.
This commit is contained in:
parent
a97d1548a6
commit
6671c91841
9 changed files with 323 additions and 103 deletions
|
|
@ -1,33 +1,16 @@
|
|||
"""User-scoped tool allow-list backed by ``SearchSourceConnector.config``.
|
||||
"""User-scoped trusted-tools list backed by ``SearchSourceConnector.config``.
|
||||
|
||||
Stores the user's "always allow" preferences as a list of tool names under
|
||||
``connector.config['trusted_tools']``. Storage is per
|
||||
``(user_id, search_space_id, connector_id)`` — i.e. tied to a specific
|
||||
connected account inside a specific workspace, exactly what the UI cares
|
||||
about.
|
||||
|
||||
Callers split into two roles:
|
||||
|
||||
- **Writers** — the ``/connectors/.../trust-tool`` and ``/untrust-tool``
|
||||
HTTP routes, and the chat resume handler when it processes a
|
||||
``{type: "always"}`` decision. Both call
|
||||
:func:`add_user_trust` / :func:`remove_user_trust`. The FE button is
|
||||
the upstream UI trigger but it talks to the routes, never to this
|
||||
module directly.
|
||||
- **Reader** — the subagent compile path, which calls
|
||||
:func:`fetch_user_allowlist_rulesets` and layers the result after the
|
||||
subagent's coded ruleset. User ``allow`` rules then override coded
|
||||
``ask`` via the rule engine's last-match-wins evaluation.
|
||||
|
||||
Coded ``deny`` rules are intentionally **not** overridable by this
|
||||
allow-list — only ``ask`` can be promoted to ``allow``. The rule engine
|
||||
enforces this naturally because user rules only ever emit ``allow``.
|
||||
Storage is per ``(user_id, search_space_id, connector_id)`` under
|
||||
``connector.config['trusted_tools']``. The list only ever encodes
|
||||
``allow`` decisions; coded ``deny`` rules cannot be overridden here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -37,10 +20,14 @@ 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.db import SearchSourceConnector
|
||||
from app.db import SearchSourceConnector, async_session_maker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRUSTED_TOOLS_KEY = "trusted_tools"
|
||||
|
||||
TrustedToolSaver = Callable[[int, str], Awaitable[None]]
|
||||
|
||||
|
||||
async def _load_owned_connector(
|
||||
session: AsyncSession,
|
||||
|
|
@ -48,11 +35,7 @@ async def _load_owned_connector(
|
|||
user_id: uuid.UUID,
|
||||
connector_id: int,
|
||||
) -> SearchSourceConnector | None:
|
||||
"""Return a connector iff it belongs to ``user_id``, else ``None``.
|
||||
|
||||
Ownership scoping is mandatory: the trust list mutates user-private
|
||||
data, callers must never write across user boundaries.
|
||||
"""
|
||||
"""Return the connector iff owned by ``user_id``, else ``None``."""
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).where(
|
||||
SearchSourceConnector.id == connector_id,
|
||||
|
|
@ -84,11 +67,7 @@ async def add_user_trust(
|
|||
connector_id: int,
|
||||
tool_name: str,
|
||||
) -> list[str]:
|
||||
"""Append ``tool_name`` to the connector's trusted list (idempotent).
|
||||
|
||||
Returns the updated trusted-tools list. Raises ``LookupError`` when
|
||||
the connector does not exist or is not owned by ``user_id``.
|
||||
"""
|
||||
"""Append ``tool_name`` to the connector's trusted list; raise ``LookupError`` if not owned."""
|
||||
connector = await _load_owned_connector(
|
||||
session, user_id=user_id, connector_id=connector_id
|
||||
)
|
||||
|
|
@ -112,11 +91,7 @@ async def remove_user_trust(
|
|||
connector_id: int,
|
||||
tool_name: str,
|
||||
) -> list[str]:
|
||||
"""Remove ``tool_name`` from the connector's trusted list (idempotent).
|
||||
|
||||
Returns the updated trusted-tools list. Raises ``LookupError`` when
|
||||
the connector does not exist or is not owned by ``user_id``.
|
||||
"""
|
||||
"""Remove ``tool_name`` from the connector's trusted list; raise ``LookupError`` if not owned."""
|
||||
connector = await _load_owned_connector(
|
||||
session, user_id=user_id, connector_id=connector_id
|
||||
)
|
||||
|
|
@ -139,20 +114,10 @@ async def fetch_user_allowlist_rulesets(
|
|||
user_id: uuid.UUID,
|
||||
search_space_id: int,
|
||||
) -> dict[str, Ruleset]:
|
||||
"""Project the user's trusted-tool lists into per-subagent rulesets.
|
||||
"""Project the user's trusted tools into per-subagent ``allow`` rulesets.
|
||||
|
||||
Walks every connector the user owns in this workspace, maps each
|
||||
``connector_type`` to its consuming subagent via
|
||||
:data:`CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS`, and emits one
|
||||
``Rule(permission=tool_name, pattern="*", action="allow")`` per
|
||||
trusted entry. Rules from different connector accounts feeding the
|
||||
same subagent (e.g. two Linear workspaces) are merged into one
|
||||
ruleset; duplicates are harmless under last-match-wins.
|
||||
|
||||
Connectors whose type is not mapped (search APIs, Github, etc.) and
|
||||
connectors with empty trust lists contribute nothing. Subagents
|
||||
with no trusted tools are absent from the returned dict — callers
|
||||
should treat ``missing == empty``.
|
||||
Subagents with no trusted tools are absent from the result —
|
||||
callers must treat ``missing == empty``.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(
|
||||
|
|
@ -189,8 +154,35 @@ async def fetch_user_allowlist_rulesets(
|
|||
}
|
||||
|
||||
|
||||
def make_trusted_tool_saver(user_id: uuid.UUID) -> TrustedToolSaver:
|
||||
"""Bind ``user_id`` into a saver closure; failures are logged, never raised."""
|
||||
|
||||
async def trusted_tool_saver(connector_id: int, tool_name: str) -> None:
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
await add_user_trust(
|
||||
session,
|
||||
user_id=user_id,
|
||||
connector_id=connector_id,
|
||||
tool_name=tool_name,
|
||||
)
|
||||
await session.commit()
|
||||
except LookupError as exc:
|
||||
logger.warning("trusted-tool save skipped: %s", exc)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"trusted-tool save failed for connector=%s tool=%s",
|
||||
connector_id,
|
||||
tool_name,
|
||||
)
|
||||
|
||||
return trusted_tool_saver
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TrustedToolSaver",
|
||||
"add_user_trust",
|
||||
"fetch_user_allowlist_rulesets",
|
||||
"make_trusted_tool_saver",
|
||||
"remove_user_trust",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue