mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
permissions/ask: gate 'approve_always' palette entry on MCP-ness
Only MCP tools have a persistence target for 'approve_always' (the connector's trusted-tools list); for native tools the decision lives only in the in-memory runtime ruleset. Reflect that in the wire palette so the FE can stay a pure renderer of allowed_decisions instead of peeking at context.mcp_connector_id to decide whether to show the 'Always Allow' button. The backend still accepts an 'approve_always' reply for any tool kind (in-memory promotion is harmless), it just doesn't advertise it when there's nowhere to persist.
This commit is contained in:
parent
c8b756ae8f
commit
98b6977c68
3 changed files with 71 additions and 8 deletions
|
|
@ -17,14 +17,21 @@ from app.agents.new_chat.permissions import Rule
|
||||||
|
|
||||||
PERMISSION_ASK_INTERRUPT_TYPE = "permission_ask"
|
PERMISSION_ASK_INTERRUPT_TYPE = "permission_ask"
|
||||||
|
|
||||||
_PERMISSION_ASK_DECISIONS: list[str] = [
|
_BASE_PERMISSION_ASK_DECISIONS: list[str] = [
|
||||||
LC_DECISION_APPROVE,
|
LC_DECISION_APPROVE,
|
||||||
LC_DECISION_REJECT,
|
LC_DECISION_REJECT,
|
||||||
LC_DECISION_EDIT,
|
LC_DECISION_EDIT,
|
||||||
SURFSENSE_DECISION_APPROVE_ALWAYS,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mcp_tool(tool: BaseTool | None) -> bool:
|
||||||
|
"""An MCP tool advertises a connector id in its langchain metadata."""
|
||||||
|
if tool is None:
|
||||||
|
return False
|
||||||
|
metadata = getattr(tool, "metadata", None) or {}
|
||||||
|
return metadata.get("mcp_connector_id") is not None
|
||||||
|
|
||||||
|
|
||||||
def _card_fields_from_tool(tool: BaseTool | None) -> dict[str, Any]:
|
def _card_fields_from_tool(tool: BaseTool | None) -> dict[str, Any]:
|
||||||
"""Project the FE card's tool-scoped fields out of a BaseTool."""
|
"""Project the FE card's tool-scoped fields out of a BaseTool."""
|
||||||
if tool is None:
|
if tool is None:
|
||||||
|
|
@ -52,10 +59,15 @@ def build_permission_ask_payload(
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build the permission-ask interrupt payload.
|
"""Build the permission-ask interrupt payload.
|
||||||
|
|
||||||
``tool`` carries the FE card's tool-scoped fields (description, MCP
|
``approve_always`` is added to the palette only for MCP tools, since that
|
||||||
connector). When omitted the card still renders, just without the
|
is the only case where the user's choice can persist beyond the current
|
||||||
"Always Allow against this connected account" surface.
|
agent instance (saved to the connector's trusted-tools list). Native
|
||||||
|
tools fall back to the once/reject/edit triad.
|
||||||
"""
|
"""
|
||||||
|
allowed_decisions = list(_BASE_PERMISSION_ASK_DECISIONS)
|
||||||
|
if _is_mcp_tool(tool):
|
||||||
|
allowed_decisions.append(SURFSENSE_DECISION_APPROVE_ALWAYS)
|
||||||
|
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {
|
||||||
"patterns": patterns,
|
"patterns": patterns,
|
||||||
"rules": [
|
"rules": [
|
||||||
|
|
@ -68,7 +80,7 @@ def build_permission_ask_payload(
|
||||||
return build_lc_hitl_payload(
|
return build_lc_hitl_payload(
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
args=args,
|
args=args,
|
||||||
allowed_decisions=_PERMISSION_ASK_DECISIONS,
|
allowed_decisions=allowed_decisions,
|
||||||
interrupt_type=PERMISSION_ASK_INTERRUPT_TYPE,
|
interrupt_type=PERMISSION_ASK_INTERRUPT_TYPE,
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,12 @@ async def test_permission_ask_payload_uses_lc_hitl_shape():
|
||||||
], f"REGRESSION: permission ask reverted to legacy shape; got {value!r}"
|
], f"REGRESSION: permission ask reverted to legacy shape; got {value!r}"
|
||||||
review = value.get("review_configs")
|
review = value.get("review_configs")
|
||||||
assert isinstance(review, list) and len(review) == 1
|
assert isinstance(review, list) and len(review) == 1
|
||||||
# ``approve_always`` must be in the palette so the FE can render the promote button.
|
palette = review[0]["allowed_decisions"]
|
||||||
assert "approve_always" in review[0]["allowed_decisions"]
|
# Native tool (no ``tool=`` argument): the palette must include the
|
||||||
|
# once/reject/edit triad. ``approve_always`` is gated on MCP-ness and
|
||||||
|
# therefore *omitted* here — palette content per tool kind is
|
||||||
|
# exercised in ``test_permission_ask_mcp_context``.
|
||||||
|
assert "approve" in palette and "reject" in palette and "edit" in palette
|
||||||
assert value.get("interrupt_type") == "permission_ask"
|
assert value.get("interrupt_type") == "permission_ask"
|
||||||
# SurfSense context rides through verbatim for FE explainability.
|
# SurfSense context rides through verbatim for FE explainability.
|
||||||
assert value["context"]["patterns"] == ["rm/*"]
|
assert value["context"]["patterns"] == ["rm/*"]
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,53 @@ def test_payload_omits_tool_fields_when_tool_is_none():
|
||||||
assert "tool_description" not in ctx
|
assert "tool_description" not in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_palette_includes_approve_always_for_mcp_tool():
|
||||||
|
"""Saving to the connector's trusted-tools list is only possible for MCP tools."""
|
||||||
|
tool = _make_mcp_tool(
|
||||||
|
name="linear_create_issue", connector_id=42, connector_name="Linear"
|
||||||
|
)
|
||||||
|
palette = build_permission_ask_payload(
|
||||||
|
tool_name=tool.name,
|
||||||
|
args={},
|
||||||
|
patterns=[tool.name],
|
||||||
|
rules=[_ask_rule(tool.name)],
|
||||||
|
tool=tool,
|
||||||
|
)["review_configs"][0]["allowed_decisions"]
|
||||||
|
assert "approve_always" in palette
|
||||||
|
|
||||||
|
|
||||||
|
def test_palette_excludes_approve_always_for_native_tool():
|
||||||
|
"""Native tools have no place to persist trust, so don't offer the button."""
|
||||||
|
native = StructuredTool(
|
||||||
|
name="rm",
|
||||||
|
description="Remove a file.",
|
||||||
|
coroutine=_noop,
|
||||||
|
args_schema=_NoArgs,
|
||||||
|
metadata={"hitl": True},
|
||||||
|
)
|
||||||
|
palette = build_permission_ask_payload(
|
||||||
|
tool_name=native.name,
|
||||||
|
args={"path": "/tmp/x"},
|
||||||
|
patterns=[native.name],
|
||||||
|
rules=[_ask_rule(native.name)],
|
||||||
|
tool=native,
|
||||||
|
)["review_configs"][0]["allowed_decisions"]
|
||||||
|
assert "approve_always" not in palette
|
||||||
|
assert palette == ["approve", "reject", "edit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_palette_excludes_approve_always_when_tool_is_none():
|
||||||
|
"""Without a tool object the middleware can't tell — fall back to the safe triad."""
|
||||||
|
palette = build_permission_ask_payload(
|
||||||
|
tool_name="rm",
|
||||||
|
args={"path": "/tmp/x"},
|
||||||
|
patterns=["rm"],
|
||||||
|
rules=[_ask_rule("rm")],
|
||||||
|
tool=None,
|
||||||
|
)["review_configs"][0]["allowed_decisions"]
|
||||||
|
assert palette == ["approve", "reject", "edit"]
|
||||||
|
|
||||||
|
|
||||||
def test_payload_omits_falsy_mcp_metadata_fields():
|
def test_payload_omits_falsy_mcp_metadata_fields():
|
||||||
tool = StructuredTool(
|
tool = StructuredTool(
|
||||||
name="anon_tool",
|
name="anon_tool",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue