Refuse public-link writes on the owner's account unless allowlisted

Someone who reaches an agent only through its public link was offered the
approval card for writes on the owner's connected accounts, so a stranger
could approve for the owner. Those writes are now refused with a tool
result, like an API-key caller's, unless the owner allowed the action in
the agent's Access details. Team members keep the card, and a tool on the
caller's own account (member mode) is unaffected. The flag survives a
resume, and workflow nodes now follow the run's caller rules (scheduled,
API-key and public-link) instead of starting from none.
This commit is contained in:
arc53-machine committed 2026-09-29 15:12:46 +01:00
1 parent 27495f0bc2
commit 3dc5080058
7 files changed
+290 -9

No files matched your search

+11 -2
View File
@@ -499,6 +499,7 @@ class ToolExecutor:
headless: bool = False,
tool_allowlist: Optional[List[str]] = None,
external_caller: bool = False,
public_link_caller: bool = False,
api_write_allowlist: Optional[List[str]] = None,
):
self.user_api_key = user_api_key
@@ -514,6 +515,10 @@ class ToolExecutor:
# uses the owner's accounts and nobody can approve, so writes on a
# connected account run only when the owner allowlisted them.
self.external_caller = bool(external_caller)
# Someone who reaches the agent only through its public link: they
# may not approve writes on the owner's account either, so those run
# only when allowlisted. Their own account (member mode) is theirs.
self.public_link_caller = bool(public_link_caller)
self.api_write_allowlist: set = {str(x) for x in api_write_allowlist or []}
# Set by BaseAgent._prepare_tools when the agent has tool-stage controls.
self.guardrail_engine = None
@@ -1155,13 +1160,17 @@ class ToolExecutor:
# An API-key caller writes on the owner's account 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.
if self.external_caller and resolved is not None:
# 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:
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"
return {
"call_id": call_id,
"name": llm_name,
@@ -1173,7 +1182,7 @@ class ToolExecutor:
"pause_type": "headless_denied",
"deny_reason": (
f"This agent can't take this action with {resolved.connector_name or 'the owner'}'s "
"account through its API key. The owner can allow it in the agent's Access details."
f"account through {route}. The owner can allow it in the agent's Access details."
),
"error_type": "tool_not_allowed",
"thought_signature": getattr(call, "thought_signature", None),
+22 -3
View File
@@ -446,9 +446,9 @@ class WorkflowEngine:
# the tool_call_attempts primary key and drop the later journal rows.
node_executor = getattr(node_agent, "tool_executor", None)
if node_executor is not None:
node_executor.message_id = getattr(
getattr(self.agent, "tool_executor", None), "message_id", None
)
run_executor = getattr(self.agent, "tool_executor", None)
node_executor.message_id = getattr(run_executor, "message_id", None)
self._inherit_caller_policy(node_executor, run_executor)
# Run-scope the node agent's tools so artifact_generator / code_executor
# address artifacts by this workflow run: a short ref (A1) created by one
# node resolves for edit_artifact in a later node within the same run. Only
@@ -1323,6 +1323,25 @@ class WorkflowEngine:
docs_together = "\n\n".join(docs_together_parts) if docs_together_parts else None
return docs, docs_together
@staticmethod
def _inherit_caller_policy(node_executor: Any, run_executor: Any) -> None:
"""Give a node's executor the caller rules of the run that started it.
A node's tools run for the same caller as the workflow agent: a
scheduled run still can't pause, and an API-key or public-link caller
still can't write on the owner's account unless it is allowlisted.
Args:
node_executor: The node agent's ``ToolExecutor``.
run_executor: The workflow agent's ``ToolExecutor``, if any.
"""
if run_executor is None:
return
for attr in ("headless", "external_caller", "public_link_caller"):
setattr(node_executor, attr, bool(getattr(run_executor, attr, False)))
for attr in ("tool_allowlist", "api_write_allowlist"):
setattr(node_executor, attr, set(getattr(run_executor, attr, None) or ()))
def _workflow_owner_id(self) -> Optional[str]:
"""The workflow's owner, whom node tools and sources run as.
+3
View File
@@ -1062,6 +1062,9 @@ class BaseAnswerResource:
"external_api_caller": getattr(
agent.tool_executor, "external_caller", False,
),
"public_link_caller": getattr(
agent.tool_executor, "public_link_caller", False,
),
"api_write_allowlist": sorted(
getattr(agent.tool_executor, "api_write_allowlist", set()),
),
@@ -270,6 +270,9 @@ class StreamProcessor:
self.retriever_config = {}
self.is_shared_usage = False
self.shared_token = None
# Set by _get_agent_key: the caller reaches the agent only through its
# public link (not its owner, no team grant).
self.public_link_usage = False
self.agent_id = self.data.get("agent_id")
# Set by _get_agent_key once access checks pass; read for keyless runs.
self._authorized_agent_row: Optional[Dict[str, Any]] = None
@@ -702,9 +705,10 @@ class StreamProcessor:
# Team-shared agents are runnable by any member with a grant
# (viewer is enough to run). Resolved live against team_members
# on the SAME connection so a revoked grant/membership denies on
# the next call; resolution failure fails closed.
# the next call; resolution failure fails closed. Checked on a
# public agent too: a teammate there is not a link user.
is_team_shared = False
if not (is_owner or is_shared_with_user) and user_id:
if not is_owner and user_id:
try:
is_team_shared = TeamScopeRepository(conn).can_read(
user_id, "agent", str(agent["id"])
@@ -717,6 +721,7 @@ class StreamProcessor:
if not (is_owner or is_shared_with_user or is_team_shared):
raise Exception("Unauthorized access to the agent")
self.public_link_usage = not (is_owner or is_team_shared)
# Authorized. Keep the row so _configure_agent can read fields that
# do not depend on an API key — a draft agent has key = NULL, and
# the builder preview runs exactly that path.
@@ -1007,6 +1012,7 @@ class StreamProcessor:
agent_id, self.initial_user_id
)
self.agent_id = str(agent_id) if agent_id else None
self.agent_config["public_link_caller"] = bool(self.agent_id and self.public_link_usage)
# Determine the effective API key (explicit > agent-derived)
effective_key = self.data.get("api_key") or self.agent_key
@@ -1729,6 +1735,7 @@ class StreamProcessor:
decoded_token=self.decoded_token,
agent_id=agent_id,
external_caller=bool(agent_config.get("external_api_caller")),
public_link_caller=bool(agent_config.get("public_link_caller")),
api_write_allowlist=agent_config.get("api_write_allowlist"),
)
tool_executor.conversation_id = conversation_id
@@ -1908,6 +1915,7 @@ class StreamProcessor:
decoded_token=self.decoded_token,
agent_id=self.agent_id,
external_caller=bool(self.agent_config.get("external_api_caller")),
public_link_caller=bool(self.agent_config.get("public_link_caller")),
api_write_allowlist=self.agent_config.get("api_write_allowlist"),
)
tool_executor.conversation_id = self.conversation_id
+3 -2
View File
@@ -213,8 +213,9 @@ class AgentConfig(BaseModel):
guardrails: GuardrailsConfig = GuardrailsConfig()
# Write actions on the owner's connected accounts that anyone calling
# the agent with its API key (widget, API) may run. Nobody can approve
# an action there, so any other such write is refused. ``tool_id:action``.
# the agent with its API key (widget, API) or through its public link may
# run. Nobody there can approve for the owner, so any other such write is
# refused. ``tool_id:action``.
api_write_allowlist: List[str] = []
@field_validator("api_write_allowlist")