Gate outside writes on the owner's stored credentials, not only connections

API-key, widget and public-link callers were held to the write allowlist
only on connected accounts, so they could still write through an API
tool or a signed-in MCP server that carries the owner's credentials. Any
write on credentials the caller doesn't hold is now refused unless the
owner allowlisted it, and a scheduled run for such a caller counts as not
holding any. The tool list names these writes per tool, so the allowlist
can offer them, and its copy now names every route it covers.
This commit is contained in:
arc53-machine committed 2026-09-29 15:41:34 +01:00
1 parent 656e3abbd0
commit 5c8b97b609
14 files changed
+243 -36

No files matched your search

+37 -6
View File
@@ -1157,20 +1157,24 @@ class ToolExecutor:
require_approval = action_access(tool_data.get("name"), action_data) == ACCESS_WRITE
# An API-key caller writes on the owner's account only with the
# An API-key caller writes with the owner's credentials only with the
# owner's say-so: nobody can approve in a widget, and "Always allow"
# was the owner's choice for themselves, not for anyone with the key.
# A public-link user is a stranger to the owner, so their approval
# can't stand in for the owner's either.
public_on_owner_account = self.public_link_caller and resolved is not None and resolved.delegated
if (self.external_caller and resolved is not None) or public_on_owner_account:
if (self.external_caller or self.public_link_caller) and self._on_owner_credentials(tool_data, resolved):
from docsgpt.connectors.permissions import ACCESS_WRITE, action_access
if action_access(tool_data.get("name"), action_data) == ACCESS_WRITE:
entry = f"{tool_data.get('id') or tool_id}:{action_name}"
if entry in self.api_write_allowlist:
return None
route = "its API key" if self.external_caller else "its public link"
route = "for API, widget or webhook callers" if self.external_caller else "from its public link"
target = (
f"the owner's {resolved.connector_name} account"
if resolved is not None and resolved.connector_name
else "the owner's credentials"
)
return {
"call_id": call_id,
"name": llm_name,
@@ -1181,8 +1185,8 @@ class ToolExecutor:
"arguments": arguments,
"pause_type": "headless_denied",
"deny_reason": (
f"This agent can't take this action with {resolved.connector_name or 'the owner'}'s "
f"account through {route}. The owner can allow it in the agent's Access details."
f"This agent can't take this action with {target} {route}. "
"The owner can allow it in the agent's Access details."
),
"error_type": "tool_not_allowed",
"thought_signature": getattr(call, "thought_signature", None),
@@ -1238,6 +1242,33 @@ class ToolExecutor:
return None
def _on_owner_credentials(self, tool_data: Dict, resolved) -> bool:
"""Whether a call would act with credentials the caller doesn't hold.
The connection's account when there is one, else the tool owner's
stored credentials (see ``holds_owner_credentials``). An API-key
caller and any scheduled run hold none of their own: the run acts as
the owner. A public-link user in the app holds their own account only.
Args:
tool_data: The ``user_tools`` row being called.
resolved: The connection ``resolve_connection`` picked, or None.
Returns:
True when the call would use someone else's credentials.
"""
from docsgpt.connectors.permissions import holds_owner_credentials
if resolved is not None:
holder = (resolved.row or {}).get("user_id")
elif holds_owner_credentials(tool_data):
holder = tool_data.get("user_id")
else:
return False
if self.external_caller or self.headless:
return True
return holder != self.user
def _remote_device_requires_approval(
self,
tool_data: Dict,
+4
View File
@@ -36,6 +36,7 @@ from docsgpt.api.user.resource_access import (
from docsgpt.api.user.team_sharing import visible_with_access
from docsgpt.connectors.catalog import base_url, definition_for_tool
from docsgpt.connectors.service import account_tool_names
from docsgpt.connectors.permissions import owner_credential_writes
from docsgpt.core.settings import settings
from docsgpt.core.url_validation import SSRFError, validate_url
from docsgpt.security.encryption import CredentialDecryptionError, decrypt_credentials, encrypt_credentials
@@ -709,6 +710,9 @@ class GetTools(Resource):
def _shape_tool(row, *, ownership="user", force_strip_secret=False):
tool_copy = _row_to_api(row)
# The writes an agent's API write allowlist can cover (read
# from the stored row, before any secret is masked).
tool_copy["owner_credential_writes"] = owner_credential_writes(row)
config_req = tool_copy.get("configRequirements", {})
if not config_req:
tool_instance = tool_manager.tools.get(tool_copy.get("name"))
+55
View File
@@ -101,3 +101,58 @@ def apply_default_permissions(tool_name: Optional[str], actions: list[dict]) ->
updated["require_approval"] = True
stamped.append(updated)
return stamped
def tool_actions(tool: dict) -> list[dict]:
"""A tool row's actions, each carrying its ``name``.
An API tool keeps its actions under ``config["actions"]`` keyed by name;
every other tool lists them in ``actions``.
"""
if tool.get("name") == "api_tool":
stored = (tool.get("config") or {}).get("actions") or {}
return [{**(action or {}), "name": name} for name, action in stored.items()]
return [action for action in tool.get("actions") or [] if isinstance(action, dict)]
def holds_owner_credentials(tool: dict) -> bool:
"""Whether ``tool`` acts with credentials stored by its owner.
A connection's account, an API tool (its headers and query values carry
the owner's keys), a stored secret, or an MCP server the owner signed in
to. Anyone running it acts as the owner there, whoever they are.
Args:
tool: A ``user_tools`` row.
Returns:
True when the tool runs on the owner's credentials.
"""
if tool.get("connection_id") or tool.get("name") == "api_tool":
return True
config = tool.get("config") or {}
if config.get("encrypted_credentials"):
return True
return tool.get("name") == "mcp_tool" and (config.get("auth_type") or "none") != "none"
def owner_credential_writes(tool: dict) -> list[str]:
"""Names of the tool's write actions that run on its owner's credentials.
These are what someone who can't approve for the owner (an API-key or
widget caller, a public-link user, a webhook) may run only when the owner
allows them in the agent's API write allowlist.
Args:
tool: A ``user_tools`` row.
Returns:
Action names, empty when the tool holds no owner credentials.
"""
if not holds_owner_credentials(tool):
return []
return [
action["name"] for action in tool_actions(tool)
if action.get("name") and action.get("active") is not False
and action_access(tool.get("name"), action) == ACCESS_WRITE
]
+18 -1
View File
@@ -34,6 +34,7 @@ const TOOLS = {
id: 'tg',
displayName: 'Telegram',
connection_id: 'c1',
owner_credential_writes: ['telegram_send_message'],
actions: [
{ name: 'telegram_send_message', access: 'write', active: true },
{ name: 'telegram_read', access: 'read', active: true },
@@ -43,8 +44,16 @@ const TOOLS = {
id: 'memory',
displayName: 'Memory',
connection_id: null,
owner_credential_writes: [],
actions: [{ name: 'memory_write', access: 'write', active: true }],
},
{
id: 'crm',
displayName: 'CRM API',
connection_id: null,
owner_credential_writes: ['create_lead'],
actions: [],
},
],
};
@@ -101,7 +110,7 @@ describe('ApiWriteAllowlist', () => {
return onConfigChange;
};
it('lists only write actions on connected accounts, unchecked by default', async () => {
it("lists only writes on the owner's credentials, unchecked by default", async () => {
await render(agent());
const labels = Array.from(container.querySelectorAll('label')).map(
(l) => l.textContent,
@@ -166,6 +175,14 @@ describe('ApiWriteAllowlist', () => {
expect(selectActionToast(store.getState())?.variant).toBe('destructive');
});
it('lists writes on stored credentials of tools without a connection', async () => {
await render(agent({ tools: ['crm'] }));
const labels = Array.from(container.querySelectorAll('label')).map(
(l) => l.textContent,
);
expect(labels).toEqual(['CRM API: Create lead']);
});
it('renders nothing for an agent without connected tools', async () => {
await render(agent({ tools: ['memory'] }));
expect(container.textContent).toBe('');
+15 -15
View File
@@ -24,6 +24,7 @@ type UserTool = {
displayName?: string;
customName?: string;
connection_id?: string | null;
owner_credential_writes?: string[];
actions?: {
name: string;
description?: string;
@@ -33,9 +34,11 @@ type UserTool = {
};
/**
* The write actions on the owner's connected accounts that anyone calling
* this agent with its API key may run. Nobody can approve an action in the
* widget or the API, so the server refuses every other such write.
* The write actions on the owner's accounts and stored credentials that
* anyone reaching this agent through its API key, widget, a webhook or its
* public link may run. Nobody there can approve an action for the owner, so
* the server refuses every other such write. The server names these writes
* per tool (`owner_credential_writes`).
*
* A toggle saves at once, on top of the agent's last saved config
* (`getSavedConfig`), so edits still pending in the form are not saved with
@@ -76,19 +79,16 @@ export default function ApiWriteAllowlist({
const agentTools = new Set(agent.tools);
setActions(
(data.tools ?? [])
.filter((tool) => agentTools.has(tool.id) && tool.connection_id)
.filter((tool) => agentTools.has(tool.id))
.flatMap((tool) =>
(tool.actions ?? [])
.filter(
(action) =>
action.access === 'write' && action.active !== false,
)
.map((action) => ({
entry: `${tool.id}:${action.name}`,
tool: tool.customName || tool.displayName || '',
action: action.name,
description: action.description ?? '',
})),
(tool.owner_credential_writes ?? []).map((name) => ({
entry: `${tool.id}:${name}`,
tool: tool.customName || tool.displayName || '',
action: name,
description:
tool.actions?.find((action) => action.name === name)
?.description ?? '',
})),
),
);
})
+2 -2
View File
@@ -1889,8 +1889,8 @@
"resetKey": "Schlüssel zurücksetzen",
"resetKeyConfirm": "Möchten Sie den API-Schlüssel wirklich zurücksetzen? Der aktuelle Schlüssel funktioniert sofort nicht mehr und diese Aktion kann nicht rückgängig gemacht werden.",
"apiWrites": {
"title": "Aktionen, die jeder mit diesem Schlüssel in deinem Namen ausführen kann",
"description": "Im Widget oder über die API kann niemand eine Aktion bestätigen. Änderungen an deinen verbundenen Konten sind daher blockiert, außer du erlaubst sie hier.",
"title": "Aktionen, die API-, Widget- und Public-Link-Nutzer in deinem Namen ausführen können",
"description": "Über den API-Schlüssel, das Widget, einen Webhook oder den öffentlichen Link des Agenten kann niemand eine Aktion für dich bestätigen. Änderungen mit deinen verbundenen Konten und gespeicherten Zugangsdaten sind daher blockiert, außer du erlaubst sie hier.",
"item": "{{tool}}: {{action}}",
"saveFailed": "Speichern fehlgeschlagen. Versuche es erneut."
},
+2 -2
View File
@@ -1895,8 +1895,8 @@
"resetKey": "Reset key",
"resetKeyConfirm": "Are you sure you want to reset the API key? The current key will stop working immediately and this action cannot be undone.",
"apiWrites": {
"title": "Actions anyone with this key can take as you",
"description": "Nobody can approve an action in the widget or through the API, so changes on your connected accounts are blocked unless you allow them here.",
"title": "Actions API, widget and public-link users can take as you",
"description": "Nobody can approve an action for you through the API key, the widget, a webhook or the agent's public link, so changes made with your connected accounts and saved credentials are blocked unless you allow them here.",
"item": "{{tool}}: {{action}}",
"saveFailed": "Could not save. Try again."
},
+2 -2
View File
@@ -1889,8 +1889,8 @@
"resetKey": "Restablecer clave",
"resetKeyConfirm": "¿Seguro que quieres restablecer la clave de API? La clave actual dejará de funcionar de inmediato y esta acción no se puede deshacer.",
"apiWrites": {
"title": "Acciones que cualquiera con esta clave puede realizar en tu nombre",
"description": "Nadie puede aprobar una acción en el widget ni a través de la API, así que los cambios en tus cuentas conectadas se bloquean salvo que los permitas aquí.",
"title": "Acciones que los usuarios de la API, del widget y del enlace público pueden realizar en tu nombre",
"description": "Nadie puede aprobar una acción por ti a través de la clave de API, el widget, un webhook o el enlace público del agente, así que los cambios con tus cuentas conectadas y credenciales guardadas se bloquean salvo que los permitas aquí.",
"item": "{{tool}}: {{action}}",
"saveFailed": "No se pudo guardar. Inténtalo de nuevo."
},
+2 -2
View File
@@ -1878,8 +1878,8 @@
"resetKey": "キーをリセット",
"resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。",
"apiWrites": {
"title": "このキーを持つ人があなたとして実行できる操作",
"description": "ウィジェットや API では誰も操作を承認できないため、接続済みアカウントへの変更はここで許可しない限りブロックされます。",
"title": "API、ウィジェット、公開リンクの利用者があなたとして実行できる操作",
"description": "API キー、ウィジェット、Webhook、エージェントの公開リンクでは誰もあなたの代わりに操作を承認できないため、接続済みアカウントや保存済みの認証情報を使う変更は、ここで許可しない限りブロックされます。",
"item": "{{tool}}: {{action}}",
"saveFailed": "保存できませんでした。もう一度お試しください。"
},
+2 -2
View File
@@ -1973,8 +1973,8 @@
"resetKey": "Сбросить ключ",
"resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить.",
"apiWrites": {
"title": "Действия, которые любой с этим ключом может выполнить от вашего имени",
"description": "В виджете и через API никто не может подтвердить действие, поэтому изменения в подключённых аккаунтах заблокированы, если вы не разрешите их здесь.",
"title": "Действия, которые пользователи API, виджета и публичной ссылки могут выполнить от вашего имени",
"description": "Через API-ключ, виджет, вебхук или публичную ссылку агента никто не может подтвердить действие за вас, поэтому изменения с вашими подключёнными аккаунтами и сохранёнными учётными данными заблокированы, если вы не разрешите их здесь.",
"item": "{{tool}}: {{action}}",
"saveFailed": "Не удалось сохранить. Попробуйте ещё раз."
},
+2 -2
View File
@@ -1878,8 +1878,8 @@
"resetKey": "重設金鑰",
"resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。",
"apiWrites": {
"title": "持有此金鑰的任何人都能以你的身分執行的操作",
"description": "在小工具或 API 中無人能核准操作,因此除非你在此允許,否則對已連線帳號的變更都會被封鎖。",
"title": "API、小工具和公開連結使用者能以你的身分執行的操作",
"description": "透過 API 金鑰、小工具、Webhook 或代理的公開連結,無人能替你核准操作,因此除非你在此允許,否則使用你的已連線帳號和已儲存憑證的變更都會被封鎖。",
"item": "{{tool}}:{{action}}",
"saveFailed": "無法儲存。請再試一次。"
},
+2 -2
View File
@@ -1878,8 +1878,8 @@
"resetKey": "重置密钥",
"resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。",
"apiWrites": {
"title": "持有此密钥的任何人都能以你的身份执行的操作",
"description": "在小组件或 API 中无人能批准操作,因此除非你在此允许,否则对已连接账号的更改都会被阻止。",
"title": "API、小组件和公开链接用户能以你的身份执行的操作",
"description": "通过 API 密钥、小组件、Webhook 或智能体的公开链接,无人能替你批准操作,因此除非你在此允许,否则使用你的已连接账号和已保存凭据的更改都会被阻止。",
"item": "{{tool}}:{{action}}",
"saveFailed": "无法保存。请重试。"
},
@@ -237,3 +237,72 @@ class TestWorkflowNodes:
assert node_executor.external_caller is True
assert node_executor.public_link_caller is True
assert node_executor.api_write_allowlist == {f"tool-1:{ACTION}"}
def _stored_tool(name: str, action: dict, config: dict) -> dict:
return {"id": "tool-9", "user_id": "alice", "name": name, "config": config, "actions": [action]}
def _api_tool(method: str) -> dict:
tool = _stored_tool("api_tool", {}, {"actions": {"call": {"url": "https://x.test", "method": method,
"active": True, "require_approval": False}}})
tool["actions"] = []
return tool
class TestOwnerHeldCredentialsWithoutAConnection:
"""Writes with the owner's stored credentials are gated like connected ones."""
def _caller(self, **flags):
from docsgpt.agents.tool_executor import ToolExecutor
flags.setdefault("user", "bob")
return ToolExecutor(**flags)
@pytest.mark.parametrize("flags", [{"public_link_caller": True}, {"external_caller": True, "user": "alice"}])
def test_api_tool_write_is_refused(self, flags):
pause = _pause(self._caller(**flags), _api_tool("POST"), action="call")
assert pause["pause_type"] == "headless_denied"
assert "Access details" in pause["deny_reason"]
def test_api_tool_read_runs(self):
assert _pause(self._caller(public_link_caller=True), _api_tool("GET"), action="call") is None
def test_allowlisted_api_tool_write_runs(self):
caller = self._caller(public_link_caller=True, api_write_allowlist=["tool-9:call"])
assert _pause(caller, _api_tool("POST"), action="call") is None
def test_mcp_tool_with_stored_sign_in_is_gated(self):
tool = _stored_tool("mcp_tool", {"name": "create_issue", "active": True},
{"server_url": "https://m.test/mcp", "auth_type": "bearer"})
pause = _pause(self._caller(public_link_caller=True), tool, action="create_issue")
assert pause["pause_type"] == "headless_denied"
def test_mcp_tool_without_credentials_is_not_gated(self):
tool = _stored_tool("mcp_tool", {"name": "create_issue", "active": True},
{"server_url": "https://m.test/mcp", "auth_type": "none"})
assert _pause(self._caller(public_link_caller=True), tool, action="create_issue") is None
def test_teammate_is_not_gated(self):
assert _pause(self._caller(), _api_tool("POST"), action="call") is None
class TestScheduledRunsForOthers:
"""A scheduled run acts as the owner, for someone who can't approve for them."""
def test_public_link_schedule_cannot_write_on_the_owners_account(self, pg_conn):
from docsgpt.agents.tool_executor import ToolExecutor
cid = _connection(pg_conn, "alice")
executor = ToolExecutor(user="alice", headless=True, public_link_caller=True)
with _db(pg_conn):
pause = _pause(executor, _tool(cid))
assert pause["pause_type"] == "headless_denied"
assert "public link" in pause["deny_reason"]
def test_owners_own_schedule_is_not_gated(self, pg_conn):
from docsgpt.agents.tool_executor import ToolExecutor
cid = _connection(pg_conn, "alice")
with _db(pg_conn):
assert _pause(ToolExecutor(user="alice", headless=True), _tool(cid)) is None
+31
View File
@@ -294,3 +294,34 @@ class TestAccountNamesInToolNames:
detail = service.connection_detail(pg_conn, named)
assert detail["account_name"] == "Alerts bot"
assert detail["tools"][0]["display_name"] == "Telegram · Alerts bot"
class TestOwnerCredentialWrites:
"""The tool list names the writes an agent's API allowlist can cover."""
def _listed(self, app, pg_conn):
from docsgpt.api.user.tools.routes import GetTools
with _db(pg_conn), app.test_request_context("/api/get_tools"):
from flask import request
request.decoded_token = {"sub": "alice"}
tools = GetTools().get().get_json()["tools"]
return {t["name"]: t.get("owner_credential_writes") for t in tools if t.get("ownership") == "user"}
def test_writes_on_stored_credentials_are_listed(self, app, pg_conn):
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
repo = UserToolsRepository(pg_conn)
repo.create("alice", "api_tool", config={"actions": {
"status": {"url": "https://x.test/s", "method": "GET", "active": True},
"notify": {"url": "https://x.test/n", "method": "POST", "active": True},
}})
repo.create("alice", "mcp_tool", config={"server_url": "https://m.test/mcp", "auth_type": "bearer"},
actions=[{"name": "create_issue", "active": True}, {"name": "list_issues", "active": True}])
repo.create("alice", "read_webpage", actions=[{"name": "post_page", "active": True}])
listed = self._listed(app, pg_conn)
assert listed["api_tool"] == ["notify"]
assert listed["mcp_tool"] == ["create_issue"]
# No credentials: nothing of the owner's to write with.
assert listed["read_webpage"] == []