mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 06:17:23 +00:00
Refuse writes on the owner's accounts from API-key callers unless allowed
An agent called with its API key (widget, API) runs as its owner, and nobody can approve an action there, so a write set to Always allow ran on the owner's account for anyone holding the key. A caller is external when the request carries the key and is not signed in as the owner (an owner previewing their agent keeps full access). For external callers, write actions on connection-backed tools are refused unless listed in the agent config's new api_write_allowlist (tool_id:action), and a missing connection is refused instead of pausing on a Connect card the widget cannot show. The flag and list survive a paused-and-resumed stream. Owners pick the allowed actions under Access details; a team editor's update keeps the stored list.
This commit is contained in:
1 parent
7c6154749a
commit
a23ace3491
19 files changed
+585
-10
No files matched your search
@@ -37,6 +37,10 @@ Sharing a tool that uses a connection asks whose account team members use:
|
||||
|
||||
Tools from OAuth MCP servers default to each member's own account. An admin can force one mode for a connector.
|
||||
|
||||
### Agents used through an API key
|
||||
|
||||
An agent called with its API key (the website widget, the API) runs with its owner's connections, and nobody can approve an action there. It can read through those connections, but it can't take write actions on them unless the owner allows each one under **Access details > Actions anyone with this key can take as you**. Only the owner can change that list; team editors can't. The owner previewing their own agent in DocsGPT is not limited.
|
||||
|
||||
### What answers show
|
||||
|
||||
Tool calls name the service that ran them: "Searched Notion", "Read from Google Drive", "Used Linear: create issue". Citations from a synced source read "From Google Drive". Answers never show which account was used.
|
||||
|
||||
@@ -447,6 +447,8 @@ class ToolExecutor:
|
||||
*,
|
||||
headless: bool = False,
|
||||
tool_allowlist: Optional[List[str]] = None,
|
||||
external_caller: bool = False,
|
||||
api_write_allowlist: Optional[List[str]] = None,
|
||||
):
|
||||
self.user_api_key = user_api_key
|
||||
self.user = user
|
||||
@@ -457,6 +459,11 @@ class ToolExecutor:
|
||||
self.headless = bool(headless)
|
||||
# Tool-instance ids pre-authorized for headless approval-gated execution.
|
||||
self.tool_allowlist: set = {str(x) for x in tool_allowlist} if tool_allowlist else set()
|
||||
# Someone calling the agent with its API key (widget, API): the run
|
||||
# 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)
|
||||
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
|
||||
self.tool_calls: List[Dict] = []
|
||||
@@ -866,7 +873,7 @@ class ToolExecutor:
|
||||
# user connects, then continues, and the pending call resumes.
|
||||
resolved = self._resolve_connection(tool_data)
|
||||
if resolved is not None and not resolved.available:
|
||||
if self.headless:
|
||||
if self.headless or self.external_caller:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"name": llm_name,
|
||||
@@ -940,6 +947,33 @@ 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
|
||||
# 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:
|
||||
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
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"name": llm_name,
|
||||
"tool_name": tool_data.get("name", "unknown"),
|
||||
"tool_id": tool_id,
|
||||
"action_name": action_name,
|
||||
"llm_name": llm_name,
|
||||
"arguments": arguments,
|
||||
"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."
|
||||
),
|
||||
"error_type": "tool_not_allowed",
|
||||
"thought_signature": getattr(call, "thought_signature", None),
|
||||
}
|
||||
|
||||
if require_approval:
|
||||
if self.headless:
|
||||
tool_row_id = str(tool_data.get("id") or tool_id)
|
||||
|
||||
@@ -1057,6 +1057,14 @@ class BaseAnswerResource:
|
||||
"llm_name": getattr(agent, "llm_name", settings.LLM_PROVIDER),
|
||||
"api_key": getattr(agent, "api_key", None),
|
||||
"user_api_key": user_api_key,
|
||||
# An API-key caller stays one after a
|
||||
# resume (owner-account write rules).
|
||||
"external_api_caller": getattr(
|
||||
agent.tool_executor, "external_caller", False,
|
||||
),
|
||||
"api_write_allowlist": sorted(
|
||||
getattr(agent.tool_executor, "api_write_allowlist", set()),
|
||||
),
|
||||
"agent_id": agent_id,
|
||||
"agent_type": agent.__class__.__name__,
|
||||
"prompt": getattr(agent, "prompt", ""),
|
||||
|
||||
@@ -29,6 +29,7 @@ from docsgpt.core.model_utils import (
|
||||
validate_model_id,
|
||||
)
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.guardrails.config import AgentConfig
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
@@ -52,6 +53,25 @@ from docsgpt.utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_external_api_caller(data: Dict[str, Any], decoded_token: Optional[Dict], owner: Optional[str]) -> bool:
|
||||
"""Whether a request calls an agent with its API key on someone else's behalf.
|
||||
|
||||
Widget and API requests carry the agent's key and run as its owner. The
|
||||
owner previewing their own agent in the app sends the key too, but is
|
||||
signed in as that owner. In local mode without auth everyone is the same
|
||||
user, so there is no one else to tell apart.
|
||||
|
||||
Args:
|
||||
data: The request body.
|
||||
decoded_token: The caller's token before the key's owner replaces it.
|
||||
owner: The agent owner's user id.
|
||||
"""
|
||||
if not data.get("api_key"):
|
||||
return False
|
||||
caller = (decoded_token or {}).get("sub")
|
||||
return not caller or caller != owner
|
||||
|
||||
|
||||
def _clamp_chunks(value: int) -> int:
|
||||
"""Bound top-k to the range ``RetrievalConfig`` enforces, keeping 0.
|
||||
|
||||
@@ -954,6 +974,13 @@ class StreamProcessor:
|
||||
)
|
||||
|
||||
# Set identity context
|
||||
owner = self._agent_data.get("user")
|
||||
self.agent_config["external_api_caller"] = is_external_api_caller(
|
||||
self.data, self.decoded_token, owner,
|
||||
)
|
||||
self.agent_config["api_write_allowlist"] = AgentConfig.parse(
|
||||
self._agent_data.get("config")
|
||||
).api_write_allowlist
|
||||
if self.data.get("api_key"):
|
||||
# External API key: use the key owner's identity
|
||||
self.initial_user_id = self._agent_data.get("user")
|
||||
@@ -1632,6 +1659,8 @@ class StreamProcessor:
|
||||
user=self.initial_user_id,
|
||||
decoded_token=self.decoded_token,
|
||||
agent_id=agent_id,
|
||||
external_caller=bool(agent_config.get("external_api_caller")),
|
||||
api_write_allowlist=agent_config.get("api_write_allowlist"),
|
||||
)
|
||||
tool_executor.conversation_id = conversation_id
|
||||
# Restore client tools so they stay available for subsequent LLM calls
|
||||
@@ -1809,6 +1838,8 @@ class StreamProcessor:
|
||||
user=user,
|
||||
decoded_token=self.decoded_token,
|
||||
agent_id=self.agent_id,
|
||||
external_caller=bool(self.agent_config.get("external_api_caller")),
|
||||
api_write_allowlist=self.agent_config.get("api_write_allowlist"),
|
||||
)
|
||||
tool_executor.conversation_id = self.conversation_id
|
||||
# Pass client-side tools so they get merged in get_tools()
|
||||
|
||||
@@ -383,6 +383,27 @@ def _build_create_kwargs(data: dict, *, image_url: str, agent_type: str) -> dict
|
||||
return kwargs
|
||||
|
||||
|
||||
def keep_owner_only_config(config: dict, existing_agent: dict, is_team_editor: bool) -> dict:
|
||||
"""Keep the parts of an agent's config only its owner may change.
|
||||
|
||||
``api_write_allowlist`` lets anyone with the agent's API key act on the
|
||||
owner's connected accounts, so a team editor's update keeps the stored
|
||||
value whatever it sends.
|
||||
|
||||
Args:
|
||||
config: The normalized config from the request.
|
||||
existing_agent: The agent row being updated.
|
||||
is_team_editor: The caller edits through a team share, not as owner.
|
||||
|
||||
Returns:
|
||||
The config to store.
|
||||
"""
|
||||
if not is_team_editor:
|
||||
return config
|
||||
stored = AgentConfig.parse(existing_agent.get("config")).api_write_allowlist
|
||||
return {**config, "api_write_allowlist": stored}
|
||||
|
||||
|
||||
def normalize_agent_config(raw):
|
||||
"""Validate an inbound ``config`` payload, returning the normalized dict.
|
||||
|
||||
@@ -1030,7 +1051,9 @@ class UpdateAgent(Resource):
|
||||
exc,
|
||||
)
|
||||
return _reject(INVALID_CONFIG_MESSAGE, user, field)
|
||||
update_fields["config"] = normalized_config or {}
|
||||
update_fields["config"] = keep_owner_only_config(
|
||||
normalized_config or {}, existing_agent, is_team_editor,
|
||||
)
|
||||
elif field == "limited_token_mode":
|
||||
raw_value = data.get("limited_token_mode", False)
|
||||
bool_value = (
|
||||
|
||||
@@ -212,6 +212,23 @@ class AgentConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
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``.
|
||||
api_write_allowlist: List[str] = []
|
||||
|
||||
@field_validator("api_write_allowlist")
|
||||
@classmethod
|
||||
def _check_allowlist(cls, value: List[str]) -> List[str]:
|
||||
if len(value) > 200:
|
||||
raise ValueError("api_write_allowlist accepts at most 200 actions")
|
||||
cleaned = []
|
||||
for entry in value:
|
||||
tool_id, _, action = str(entry).partition(":")
|
||||
if not tool_id.strip() or not action.strip():
|
||||
raise ValueError("api_write_allowlist entries must be 'tool_id:action'")
|
||||
cleaned.append(f"{tool_id.strip()}:{action.strip()}")
|
||||
return sorted(set(cleaned))
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw: Optional[dict]) -> "AgentConfig":
|
||||
@@ -221,4 +238,5 @@ class AgentConfig(BaseModel):
|
||||
try:
|
||||
return cls.model_validate(raw)
|
||||
except Exception:
|
||||
# A bad allowlist falls back to none: the safe side.
|
||||
return cls(guardrails=GuardrailsConfig.parse(raw.get("guardrails")))
|
||||
@@ -0,0 +1,142 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.action ? `${opts.tool}: ${opts.action}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const getUserTools = vi.fn();
|
||||
const updateAgent = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getUserTools: (...args: unknown[]) => getUserTools(...args),
|
||||
updateAgent: (...args: unknown[]) => updateAgent(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import actionToastReducer, {
|
||||
selectActionToast,
|
||||
} from '../notifications/actionToastSlice';
|
||||
import { prefSlice } from '../preferences/preferenceSlice';
|
||||
import ApiWriteAllowlist from './ApiWriteAllowlist';
|
||||
import type { Agent } from './types';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const TOOLS = {
|
||||
tools: [
|
||||
{
|
||||
id: 'tg',
|
||||
displayName: 'Telegram',
|
||||
connection_id: 'c1',
|
||||
actions: [
|
||||
{ name: 'telegram_send_message', access: 'write', active: true },
|
||||
{ name: 'telegram_read', access: 'read', active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
displayName: 'Memory',
|
||||
connection_id: null,
|
||||
actions: [{ name: 'memory_write', access: 'write', active: true }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const agent = (overrides: Partial<Agent> = {}): Agent =>
|
||||
({
|
||||
id: 'agent-1',
|
||||
tools: ['tg', 'memory'],
|
||||
config: { guardrails: { controls: [] } },
|
||||
...overrides,
|
||||
}) as unknown as Agent;
|
||||
|
||||
describe('ApiWriteAllowlist', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let store: ReturnType<typeof makeStore>;
|
||||
const makeStore = () =>
|
||||
configureStore({
|
||||
reducer: {
|
||||
preference: prefSlice.reducer,
|
||||
actionToast: actionToastReducer,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
getUserTools.mockResolvedValue({ json: async () => TOOLS });
|
||||
updateAgent.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (a: Agent, onConfigChange = vi.fn()) => {
|
||||
store = makeStore();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<ApiWriteAllowlist agent={a} onConfigChange={onConfigChange} />
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
return onConfigChange;
|
||||
};
|
||||
|
||||
it('lists only write actions on connected accounts, unchecked by default', async () => {
|
||||
await render(agent());
|
||||
const labels = Array.from(container.querySelectorAll('label')).map(
|
||||
(l) => l.textContent,
|
||||
);
|
||||
expect(labels).toEqual(['Telegram: telegram_send_message']);
|
||||
expect(
|
||||
container
|
||||
.querySelector('button[role="checkbox"]')!
|
||||
.getAttribute('aria-checked'),
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('saves the allowlist without dropping the rest of the config', async () => {
|
||||
updateAgent.mockResolvedValue({ ok: true });
|
||||
const onConfigChange = await render(agent());
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('button[role="checkbox"]')!
|
||||
.click(),
|
||||
);
|
||||
const form = updateAgent.mock.calls[0][1] as FormData;
|
||||
expect(JSON.parse(form.get('config') as string)).toEqual({
|
||||
guardrails: { controls: [] },
|
||||
api_write_allowlist: ['tg:telegram_send_message'],
|
||||
});
|
||||
expect(onConfigChange).toHaveBeenCalledWith({
|
||||
guardrails: { controls: [] },
|
||||
api_write_allowlist: ['tg:telegram_send_message'],
|
||||
});
|
||||
});
|
||||
|
||||
it('puts the choice back and says so when saving fails', async () => {
|
||||
updateAgent.mockResolvedValue({ ok: false });
|
||||
await render(agent());
|
||||
const box = () =>
|
||||
container.querySelector<HTMLButtonElement>('button[role="checkbox"]')!;
|
||||
await act(async () => box().click());
|
||||
expect(box().getAttribute('aria-checked')).toBe('false');
|
||||
expect(selectActionToast(store.getState())?.variant).toBe('destructive');
|
||||
});
|
||||
|
||||
it('renders nothing for an agent without connected tools', async () => {
|
||||
await render(agent({ tools: ['memory'] }));
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Checkbox } from '../components/ui/checkbox';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import type { Agent, AgentConfig } from './types';
|
||||
|
||||
type WriteAction = {
|
||||
entry: string;
|
||||
tool: string;
|
||||
action: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type UserTool = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
customName?: string;
|
||||
connection_id?: string | null;
|
||||
actions?: {
|
||||
name: string;
|
||||
description?: string;
|
||||
access?: string;
|
||||
active?: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function ApiWriteAllowlist({
|
||||
agent,
|
||||
onConfigChange,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onConfigChange?: (config: AgentConfig) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const token = useSelector(selectToken);
|
||||
const [actions, setActions] = useState<WriteAction[]>([]);
|
||||
const [allowed, setAllowed] = useState<string[]>(
|
||||
agent.config?.api_write_allowlist ?? [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowed(agent.config?.api_write_allowlist ?? []);
|
||||
}, [agent.config?.api_write_allowlist]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent.id || !agent.tools?.length) {
|
||||
setActions([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
userService
|
||||
.getUserTools(token)
|
||||
.then((response: Response) => response.json())
|
||||
.then((data: { tools?: UserTool[] }) => {
|
||||
if (cancelled) return;
|
||||
const agentTools = new Set(agent.tools);
|
||||
setActions(
|
||||
(data.tools ?? [])
|
||||
.filter((tool) => agentTools.has(tool.id) && tool.connection_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 ?? '',
|
||||
})),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => !cancelled && setActions([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agent.id, agent.tools, token]);
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
const toggle = async (entry: string, checked: boolean) => {
|
||||
const previous = allowed;
|
||||
const next = checked
|
||||
? [...allowed, entry]
|
||||
: allowed.filter((item) => item !== entry);
|
||||
setAllowed(next);
|
||||
const config: AgentConfig = {
|
||||
...(agent.config ?? {}),
|
||||
api_write_allowlist: next,
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append('config', JSON.stringify(config));
|
||||
try {
|
||||
const response = await userService.updateAgent(
|
||||
agent.id ?? '',
|
||||
form,
|
||||
token,
|
||||
);
|
||||
if (!response.ok) throw new Error('save failed');
|
||||
onConfigChange?.(config);
|
||||
} catch {
|
||||
setAllowed(previous);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('modals.agentDetails.apiWrites.saveFailed'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
as="h4"
|
||||
size="xs"
|
||||
title={t('modals.agentDetails.apiWrites.title')}
|
||||
description={t('modals.agentDetails.apiWrites.description')}
|
||||
/>
|
||||
<Card padding="sm" className="gap-3">
|
||||
{actions.map((item) => {
|
||||
const id = `api-write-${item.entry}`;
|
||||
return (
|
||||
<div key={item.entry} className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={allowed.includes(item.entry)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggle(item.entry, checked === true)
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Label htmlFor={id} className="font-normal">
|
||||
{t('modals.agentDetails.apiWrites.item', {
|
||||
tool: item.tool,
|
||||
action: item.action,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</Label>
|
||||
{item.description && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1546,6 +1546,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
modalState={agentDetails}
|
||||
setModalState={setAgentDetails}
|
||||
onKeyRegenerated={(key) => setAgent((prev) => ({ ...prev, key }))}
|
||||
onConfigChange={(config) => setAgent((prev) => ({ ...prev, config }))}
|
||||
/>
|
||||
{shareModalOpen && agent.id && (
|
||||
<ShareToTeamModal
|
||||
|
||||
@@ -77,6 +77,8 @@ export type GuardrailsConfig = {
|
||||
|
||||
export type AgentConfig = {
|
||||
guardrails?: GuardrailsConfig;
|
||||
/** `tool_id:action` writes on the owner's accounts API-key callers may run. */
|
||||
api_write_allowlist?: string[];
|
||||
};
|
||||
|
||||
export type GuardrailCheckInfo = {
|
||||
|
||||
@@ -1552,7 +1552,13 @@
|
||||
"test": "Testen",
|
||||
"learnMore": "Mehr erfahren",
|
||||
"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."
|
||||
"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.",
|
||||
"item": "{{tool}}: {{action}}",
|
||||
"saveFailed": "Speichern fehlgeschlagen. Versuche es erneut."
|
||||
}
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "API-Spezifikation importieren",
|
||||
|
||||
@@ -1558,7 +1558,13 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"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."
|
||||
"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.",
|
||||
"item": "{{tool}}: {{action}}",
|
||||
"saveFailed": "Could not save. Try again."
|
||||
}
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Import API Specification",
|
||||
|
||||
@@ -1552,7 +1552,13 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"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."
|
||||
"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í.",
|
||||
"item": "{{tool}}: {{action}}",
|
||||
"saveFailed": "No se pudo guardar. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Importar especificación de API",
|
||||
|
||||
@@ -1538,7 +1538,13 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "キーをリセット",
|
||||
"resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。"
|
||||
"resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。",
|
||||
"apiWrites": {
|
||||
"title": "このキーを持つ人があなたとして実行できる操作",
|
||||
"description": "ウィジェットや API では誰も操作を承認できないため、接続済みアカウントへの変更はここで許可しない限りブロックされます。",
|
||||
"item": "{{tool}}: {{action}}",
|
||||
"saveFailed": "保存できませんでした。もう一度お試しください。"
|
||||
}
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "API仕様のインポート",
|
||||
|
||||
@@ -1618,7 +1618,13 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Сбросить ключ",
|
||||
"resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить."
|
||||
"resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить.",
|
||||
"apiWrites": {
|
||||
"title": "Действия, которые любой с этим ключом может выполнить от вашего имени",
|
||||
"description": "В виджете и через API никто не может подтвердить действие, поэтому изменения в подключённых аккаунтах заблокированы, если вы не разрешите их здесь.",
|
||||
"item": "{{tool}}: {{action}}",
|
||||
"saveFailed": "Не удалось сохранить. Попробуйте ещё раз."
|
||||
}
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Импорт спецификации API",
|
||||
|
||||
@@ -1538,7 +1538,13 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "重設金鑰",
|
||||
"resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。"
|
||||
"resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。",
|
||||
"apiWrites": {
|
||||
"title": "持有此金鑰的任何人都能以你的身分執行的操作",
|
||||
"description": "在小工具或 API 中無人能核准操作,因此除非你在此允許,否則對已連線帳號的變更都會被封鎖。",
|
||||
"item": "{{tool}}:{{action}}",
|
||||
"saveFailed": "無法儲存。請再試一次。"
|
||||
}
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "匯入 API 規格",
|
||||
|
||||
@@ -1538,7 +1538,13 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "重置密钥",
|
||||
"resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。"
|
||||
"resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。",
|
||||
"apiWrites": {
|
||||
"title": "持有此密钥的任何人都能以你的身份执行的操作",
|
||||
"description": "在小组件或 API 中无人能批准操作,因此除非你在此允许,否则对已连接账号的更改都会被阻止。",
|
||||
"item": "{{tool}}:{{action}}",
|
||||
"saveFailed": "无法保存。请重试。"
|
||||
}
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "导入 API 规范",
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { Agent } from '../agents/types';
|
||||
import ApiWriteAllowlist from '../agents/ApiWriteAllowlist';
|
||||
import { Agent, type AgentConfig } from '../agents/types';
|
||||
import userService from '../api/services/userService';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
import { Button } from '../components/ui/button';
|
||||
@@ -22,6 +23,8 @@ type AgentDetailsModalProps = {
|
||||
modalState: ActiveState;
|
||||
setModalState: (state: ActiveState) => void;
|
||||
onKeyRegenerated?: (key: string) => void;
|
||||
/** The agent's config after its API-key write allowlist changed. */
|
||||
onConfigChange?: (config: AgentConfig) => void;
|
||||
};
|
||||
|
||||
export default function AgentDetailsModal({
|
||||
@@ -30,6 +33,7 @@ export default function AgentDetailsModal({
|
||||
modalState,
|
||||
setModalState,
|
||||
onKeyRegenerated,
|
||||
onConfigChange,
|
||||
}: AgentDetailsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -221,6 +225,12 @@ export default function AgentDetailsModal({
|
||||
{t('modals.agentDetails.generate')}
|
||||
</Button>
|
||||
)}
|
||||
{apiKey && (
|
||||
<ApiWriteAllowlist
|
||||
agent={agent}
|
||||
onConfigChange={onConfigChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -351,3 +351,96 @@ class TestMcpServerMismatch:
|
||||
# Someone else's connection, or one that signs in another way, is not kept.
|
||||
assert _previous_connection(existing, same, "bob") is None
|
||||
assert _previous_connection(existing, {**same, "auth_type": "oauth"}, "alice") is None
|
||||
|
||||
|
||||
class TestExternalApiCallers:
|
||||
"""An agent called with its API key runs as the owner, and nobody can approve there."""
|
||||
|
||||
def _external(self, allowlist=None):
|
||||
from docsgpt.agents.tool_executor import ToolExecutor
|
||||
|
||||
return ToolExecutor(user="alice", external_caller=True, api_write_allowlist=allowlist)
|
||||
|
||||
def test_owner_account_write_is_denied(self, pg_conn):
|
||||
cid = _connection(pg_conn)
|
||||
with _service_db(pg_conn):
|
||||
pause = _pause(self._external(), _tool(cid))
|
||||
assert pause["pause_type"] == "headless_denied"
|
||||
assert pause["error_type"] == "tool_not_allowed"
|
||||
assert "Access details" in pause["deny_reason"]
|
||||
|
||||
def test_even_always_allow_writes_are_denied(self, pg_conn):
|
||||
cid = _connection(pg_conn)
|
||||
tool = _tool(cid)
|
||||
tool["actions"][0]["require_approval"] = False
|
||||
with _service_db(pg_conn):
|
||||
assert _pause(self._external(), tool)["pause_type"] == "headless_denied"
|
||||
|
||||
def test_allowlisted_write_runs(self, pg_conn):
|
||||
cid = _connection(pg_conn)
|
||||
with _service_db(pg_conn):
|
||||
pause = _pause(self._external(["tool-1:telegram_send_message"]), _tool(cid))
|
||||
assert pause is None
|
||||
|
||||
def test_allowlist_does_not_cover_other_actions(self, pg_conn):
|
||||
cid = _connection(pg_conn)
|
||||
with _service_db(pg_conn):
|
||||
pause = _pause(self._external(["tool-1:telegram_send_image"]), _tool(cid))
|
||||
assert pause["pause_type"] == "headless_denied"
|
||||
|
||||
def test_missing_connection_is_denied_not_paused(self, pg_conn):
|
||||
"""The widget cannot show a Connect card."""
|
||||
cid = _connection(pg_conn, status="reconnect_needed")
|
||||
with _service_db(pg_conn):
|
||||
pause = _pause(self._external(), _tool(cid))
|
||||
assert pause["pause_type"] == "headless_denied"
|
||||
assert pause["error_type"] == "connection_required"
|
||||
|
||||
def test_the_owner_in_the_app_is_not_external(self, pg_conn):
|
||||
cid = _connection(pg_conn)
|
||||
with _service_db(pg_conn):
|
||||
assert _pause(_executor(), _tool(cid)) is None
|
||||
|
||||
|
||||
class TestExternalCallerDetection:
|
||||
def test_api_key_request_from_someone_else_is_external(self):
|
||||
from docsgpt.api.answer.services.stream_processor import is_external_api_caller
|
||||
|
||||
assert is_external_api_caller({"api_key": "k"}, {"sub": "visitor"}, "alice") is True
|
||||
assert is_external_api_caller({"api_key": "k"}, None, "alice") is True
|
||||
|
||||
def test_owner_previewing_their_agent_is_not_external(self):
|
||||
from docsgpt.api.answer.services.stream_processor import is_external_api_caller
|
||||
|
||||
assert is_external_api_caller({"api_key": "k"}, {"sub": "alice"}, "alice") is False
|
||||
assert is_external_api_caller({}, {"sub": "visitor"}, "alice") is False
|
||||
|
||||
|
||||
class TestApiWriteAllowlistConfig:
|
||||
def test_accepts_tool_action_pairs(self):
|
||||
from docsgpt.guardrails.config import AgentConfig
|
||||
|
||||
config = AgentConfig.model_validate({"api_write_allowlist": ["tool-1:telegram_send_message"]})
|
||||
assert config.api_write_allowlist == ["tool-1:telegram_send_message"]
|
||||
|
||||
def test_rejects_malformed_entries(self):
|
||||
from docsgpt.guardrails.config import AgentConfig
|
||||
|
||||
with pytest.raises(Exception):
|
||||
AgentConfig.model_validate({"api_write_allowlist": ["no-action-part"]})
|
||||
|
||||
def test_old_configs_still_parse(self):
|
||||
from docsgpt.guardrails.config import AgentConfig
|
||||
|
||||
assert AgentConfig.parse({"guardrails": {}}).api_write_allowlist == []
|
||||
|
||||
|
||||
class TestAllowlistOwnership:
|
||||
def test_team_editor_cannot_change_the_allowlist(self):
|
||||
from docsgpt.api.user.agents.routes import keep_owner_only_config
|
||||
|
||||
existing = {"config": {"api_write_allowlist": ["t:a"]}}
|
||||
sent = {"guardrails": {"controls": []}, "api_write_allowlist": ["t:a", "t:b"]}
|
||||
assert keep_owner_only_config(sent, existing, True)["api_write_allowlist"] == ["t:a"]
|
||||
assert keep_owner_only_config(sent, existing, False)["api_write_allowlist"] == ["t:a", "t:b"]
|
||||
assert keep_owner_only_config({}, {"config": None}, True) == {"api_write_allowlist": []}
|
||||
Reference in new issue
Block a user