From 259da38f176a382b14d023a02a6d28a7e20eb17d Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:14 +0100 Subject: [PATCH] Classify connector actions by whole name words The read fallback matched read verbs as substrings, so update_spreadsheet, set_budget and enlist_member ran as reads with no approval. Names are now split into words, and any write verb makes the action a write. --- docsgpt/connectors/permissions.py | 28 ++++++++++++++++++++++++---- tests/connectors/test_permissions.py | 17 +++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/docsgpt/connectors/permissions.py b/docsgpt/connectors/permissions.py index b5afce78..254631df 100644 --- a/docsgpt/connectors/permissions.py +++ b/docsgpt/connectors/permissions.py @@ -9,6 +9,7 @@ on the action itself: ``active`` off means "Off", ``require_approval`` means from __future__ import annotations +import re from typing import Optional ACCESS_READ = "read" @@ -19,7 +20,25 @@ PERMISSION_ASK = "ask" PERMISSION_OFF = "off" PERMISSIONS = (PERMISSION_ALWAYS, PERMISSION_ASK, PERMISSION_OFF) -_READ_WORDS = ("search", "query", "find", "list", "get", "read", "fetch", "lookup", "describe", "retrieve") +_READ_WORDS = frozenset( + ("search", "query", "find", "list", "get", "read", "fetch", "lookup", "describe", "retrieve") +) +# A name holding any of these is a write even when it also holds a read word +# (``get_or_create_page``, ``search_and_replace``). +_WRITE_WORDS = frozenset(( + "create", "update", "delete", "remove", "set", "send", "post", "put", "patch", "write", "add", "insert", + "upsert", "append", "replace", "edit", "modify", "rename", "move", "copy", "upload", "save", "submit", + "publish", "share", "invite", "assign", "archive", "restore", "reset", "clear", "purge", "drop", "execute", + "run", "invoke", "trigger", "start", "stop", "cancel", "close", "merge", "approve", "reject", "reply", + "comment", "mark", "enable", "disable", "grant", "revoke", "transfer", "import", "sync", "lock", "unlock", +)) +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") +_NON_WORD = re.compile(r"[^A-Za-z0-9]+") + + +def _name_words(name: str) -> set[str]: + """The lower-cased words of an action name (``snake``, ``kebab``, ``camelCase``).""" + return {word.lower() for word in _NON_WORD.split(_CAMEL_BOUNDARY.sub(" ", name)) if word} def action_access(tool_name: Optional[str], action: dict) -> str: @@ -27,7 +46,8 @@ def action_access(tool_name: Optional[str], action: dict) -> str: Order of evidence: an explicit ``access`` on the action metadata, MCP tool annotations (``readOnlyHint`` / ``destructiveHint``), the HTTP method of an - API tool action, then the action's name. + API tool action, then the action's name: a read only when one of its + words is a read verb and none is a write verb. Args: tool_name: The ``user_tools`` name the action belongs to. @@ -48,8 +68,8 @@ def action_access(tool_name: Optional[str], action: dict) -> str: method = (action.get("method") or "").upper() if tool_name == "api_tool" and method: return ACCESS_READ if method in ("GET", "HEAD", "OPTIONS") else ACCESS_WRITE - name = (action.get("name") or "").lower() - return ACCESS_READ if any(word in name for word in _READ_WORDS) else ACCESS_WRITE + words = _name_words(action.get("name") or "") + return ACCESS_READ if words & _READ_WORDS and not words & _WRITE_WORDS else ACCESS_WRITE def action_permission(action: dict) -> str: diff --git a/tests/connectors/test_permissions.py b/tests/connectors/test_permissions.py index bb4318e1..84c37595 100644 --- a/tests/connectors/test_permissions.py +++ b/tests/connectors/test_permissions.py @@ -26,6 +26,23 @@ class TestActionAccess: def test_name_heuristic(self, name, expected): assert p.action_access("mcp_tool", {"name": name}) == expected + @pytest.mark.parametrize( + "name", + ["update_spreadsheet", "create_thread", "set_budget", "update_target", "enlist_member", "threadReply"], + ) + def test_read_word_inside_another_word_is_not_a_read(self, name): + assert p.action_access("mcp_tool", {"name": name}) == "write" + + @pytest.mark.parametrize( + "name", ["get_or_create_page", "search_and_replace", "list-and-delete", "findAndUpdateRecord"] + ) + def test_a_write_verb_wins_over_a_read_verb(self, name): + assert p.action_access("mcp_tool", {"name": name}) == "write" + + @pytest.mark.parametrize("name", ["getSpreadsheet", "list-issues", "Search Pages", "fetchURLContent", "get"]) + def test_read_verbs_match_as_whole_words(self, name): + assert p.action_access("mcp_tool", {"name": name}) == "read" + class TestPermissions: def test_permission_from_flags(self):