Q104: connector grants can narrow a toolkit to named actions and fixed argument values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b02834893f
commit
558d678ba2
14 files changed
+202
-26
No files matched your search
@@ -0,0 +1,7 @@
|
||||
export const connectorGrantLimitsSchema = `
|
||||
alter table agent_connector_grants add column allowed_actions text;
|
||||
alter table agent_connector_grants add column fixed_arguments text;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (84, 'connector_grant_limits', datetime('now'));
|
||||
`;
|
||||
@@ -82,6 +82,7 @@ import { runCapabilityWarningsSchema } from "./migrations/080-run-capability-war
|
||||
import { connectorExecutionsSchema } from "./migrations/081-connector-executions";
|
||||
import { connectorPendingAccountSchema } from "./migrations/082-connector-pending-account";
|
||||
import { connectorAccountLabelsSchema } from "./migrations/083-connector-account-labels";
|
||||
import { connectorGrantLimitsSchema } from "./migrations/084-connector-grant-limits";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -173,6 +174,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 81, name: "connector_executions", sql: connectorExecutionsSchema },
|
||||
{ version: 82, name: "connector_pending_account", sql: connectorPendingAccountSchema },
|
||||
{ version: 83, name: "connector_account_labels", sql: connectorAccountLabelsSchema },
|
||||
{ version: 84, name: "connector_grant_limits", sql: connectorGrantLimitsSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { requireString } from "./agent-tool-args";
|
||||
import { optionalStringArray, requireString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import { ConnectorGrantsService } from "./composio/connector-grants";
|
||||
import { ConnectorGrantsService, type ConnectorGrantLimits } from "./composio/connector-grants";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
|
||||
/** Company connector visibility for any agent: what is connected, who holds it. */
|
||||
@@ -18,8 +18,7 @@ export function listConnectorsTool(deps: AgentToolDeps, ctx: AgentToolContext):
|
||||
const lines = accounts.map((account) => {
|
||||
const holders = grants
|
||||
.filter((grant) => grant.toolkitSlug === account.toolkitSlug && grant.accountLabel === account.label)
|
||||
.map((grant) => agentNames.get(grant.agentId))
|
||||
.filter(Boolean);
|
||||
.map((grant) => `${agentNames.get(grant.agentId)}${limitNote(grant.limits)}`);
|
||||
return `- ${account.toolkitName} (${account.toolkitSlug}), account "${account.label}": ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`;
|
||||
});
|
||||
return ["Connected toolkits:", ...lines].join("\n");
|
||||
@@ -45,11 +44,38 @@ export function grantConnectorTool(deps: AgentToolDeps, ctx: AgentToolContext, a
|
||||
agentId: target.id,
|
||||
toolkitSlug: toolkit,
|
||||
accountLabel: args.account === undefined ? null : requireString(args.account, "account"),
|
||||
limits: {
|
||||
actions: args.actions === undefined ? null : optionalStringArray(args.actions, "actions"),
|
||||
fixedArguments: args.fixed_arguments === undefined ? null : requireObject(args.fixed_arguments, "fixed_arguments"),
|
||||
},
|
||||
grantedByType: "agent",
|
||||
grantedById: ctx.agent.id,
|
||||
});
|
||||
return `${target.name} now has access to the ${toolkit.toLowerCase()} connector through its "${label}" account; its tools join their next run.`;
|
||||
return `${target.name} now has access to the ${toolkit.toLowerCase()} connector through its "${label}" account${limitText(args)}; its tools join their next run.`;
|
||||
}
|
||||
grantsService.revoke({ orgId: ctx.orgId, agentId: target.id, toolkitSlug: toolkit });
|
||||
return `${target.name} no longer has access to the ${toolkit.toLowerCase()} connector.`;
|
||||
}
|
||||
|
||||
function requireObject(value: unknown, field: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).length === 0) {
|
||||
throw new Error(`"${field}" must be an object of argument values.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function limitText(args: JsonRecord): string {
|
||||
return limitNote({
|
||||
actions: Array.isArray(args.actions) ? args.actions.map((action) => String(action).trim().toUpperCase()) : null,
|
||||
fixedArguments: args.fixed_arguments === undefined ? null : args.fixed_arguments as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
/** A grant's limits in a few words, empty when it has none. */
|
||||
function limitNote(limits: ConnectorGrantLimits): string {
|
||||
const parts = [
|
||||
...(limits.actions ? [`only ${limits.actions.join(", ")}`] : []),
|
||||
...(limits.fixedArguments ? [`limited to ${JSON.stringify(limits.fixedArguments)}`] : []),
|
||||
];
|
||||
return parts.length > 0 ? ` (${parts.join("; ")})` : "";
|
||||
}
|
||||
@@ -189,6 +189,8 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
||||
agent_name: { type: "string", description: "The teammate receiving access." },
|
||||
toolkit: { type: "string", description: "Connected toolkit slug, e.g. 'github'." },
|
||||
account: { type: "string", description: "Which of the toolkit's accounts, by label (see list_connectors); required when it has several." },
|
||||
actions: { type: "array", items: { type: "string" }, description: "Only these exact action names, e.g. read-only actions; omit to allow every action." },
|
||||
fixed_arguments: { type: "object", description: "Argument values every action must take, e.g. {\"repo\": \"site\"}; actions without these arguments are refused." },
|
||||
},
|
||||
required: ["agent_name", "toolkit"],
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ToolDefinition, ToolCall } from "../provider-tool-turn";
|
||||
import { ComposioClient, ComposioHttpError, type ComposioToolExecutionResult, type ComposioToolSchema } from "./composio-client";
|
||||
import { beginConnectorExecution, settleConnectorExecution } from "./connector-executions";
|
||||
import type { IntegrationKeyStore } from "../integration-key-store";
|
||||
import { ConnectorGrantsService } from "./connector-grants";
|
||||
import { ConnectorGrantsService, grantAllowsAction, grantLimitRefusal } from "./connector-grants";
|
||||
import { currentRunId } from "../run-control";
|
||||
|
||||
const COMPOSIO_TOOL_PREFIX = "composio_";
|
||||
@@ -70,7 +70,7 @@ export async function executeComposioTool(
|
||||
const toolkits = new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId);
|
||||
if (toolkits.length === 0) return "ERROR: You hold no connector grants. Ask the CEO to grant you one (grant_connector).";
|
||||
try {
|
||||
if (call.name === FIND_ACTIONS_TOOL) return await findActions(db, apiKey, toolkits, call.arguments);
|
||||
if (call.name === FIND_ACTIONS_TOOL) return await findActions(db, apiKey, orgId, agentId, toolkits, call.arguments);
|
||||
if (call.name === RUN_ACTION_TOOL) return await runAction(db, apiKey, orgId, agentId, toolkits, call.arguments);
|
||||
return `ERROR: ${call.name} is not a connector tool; use ${FIND_ACTIONS_TOOL} and ${RUN_ACTION_TOOL}.`;
|
||||
} catch (error) {
|
||||
@@ -78,12 +78,19 @@ export async function executeComposioTool(
|
||||
}
|
||||
}
|
||||
|
||||
async function findActions(db: DatabaseSync, apiKey: string, toolkits: string[], args: Record<string, unknown>): Promise<string> {
|
||||
async function findActions(db: DatabaseSync, apiKey: string, orgId: string, agentId: string, toolkits: string[], args: Record<string, unknown>): Promise<string> {
|
||||
const query = typeof args.query === "string" ? args.query.trim().toLowerCase() : "";
|
||||
if (!query) return "ERROR: query is required: say in a few words what you want to do.";
|
||||
const toolkit = typeof args.toolkit === "string" ? args.toolkit.toLowerCase() : null;
|
||||
if (toolkit && !toolkits.includes(toolkit)) return `ERROR: You hold no grant for "${toolkit}". Your services: ${toolkits.join(", ")}.`;
|
||||
const actions = (await actionCatalog(db, apiKey, toolkits)).filter((action) => !toolkit || action.toolkitSlug?.toLowerCase() === toolkit);
|
||||
const grants = new ConnectorGrantsService(db);
|
||||
const limits = new Map(toolkits.map((slug) => [slug, grants.limitsForAgent(orgId, agentId, slug)]));
|
||||
// Actions a grant does not allow are not shown at all.
|
||||
const actions = (await actionCatalog(db, apiKey, toolkits)).filter((action) => {
|
||||
const slug = action.toolkitSlug?.toLowerCase();
|
||||
const allowed = slug ? limits.get(slug) : undefined;
|
||||
return (!toolkit || slug === toolkit) && allowed !== undefined && grantAllowsAction(allowed, action.name);
|
||||
});
|
||||
const words = query.split(/[^a-z0-9]+/).filter(Boolean);
|
||||
const scored = actions
|
||||
.map((action) => {
|
||||
@@ -111,9 +118,12 @@ async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentI
|
||||
return `ERROR: You have no grant for the "${toolkit}" connector. Ask the CEO to grant it to you (grant_connector).`;
|
||||
}
|
||||
if (args.account !== undefined && typeof args.account !== "string") return "ERROR: account must be the label of one of the service's accounts.";
|
||||
const account = new ConnectorGrantsService(db).accountForAgent(orgId, agentId, toolkit, args.account ?? null);
|
||||
const grants = new ConnectorGrantsService(db);
|
||||
const account = grants.accountForAgent(orgId, agentId, toolkit, args.account ?? null);
|
||||
const input = typeof args.arguments === "object" && args.arguments !== null && !Array.isArray(args.arguments) ? args.arguments as Record<string, unknown> : null;
|
||||
if (!input) return "ERROR: arguments must be an object matching the action's input.";
|
||||
const refusal = grantLimitRefusal(grants.limitsForAgent(orgId, agentId, toolkit), toolkit, action, input);
|
||||
if (refusal) return `ERROR: ${refusal}`;
|
||||
if (args.confirm_not_done !== undefined && typeof args.confirm_not_done !== "boolean") return "ERROR: confirm_not_done must be true or false.";
|
||||
const started = beginConnectorExecution(db, { orgId, agentId, runId: currentRunId(), action: action.name, accountLabel: account.label, args: input, confirmedNotDone: args.confirm_not_done === true });
|
||||
if ("blockedBy" in started) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands/composio";
|
||||
import { createId } from "../../../shared/ids";
|
||||
import { canonicalJson } from "./connector-executions";
|
||||
|
||||
export const DEFAULT_ACCOUNT_LABEL = DEFAULT_CONNECTOR_ACCOUNT_LABEL;
|
||||
const MAX_LABEL_CHARS = 40;
|
||||
@@ -20,26 +21,39 @@ export interface ConnectorGrantRow {
|
||||
agentId: string;
|
||||
toolkitSlug: string;
|
||||
accountLabel: string;
|
||||
limits: ConnectorGrantLimits;
|
||||
}
|
||||
|
||||
/** What a grant narrows the toolkit to: only these actions, and these argument values on every call; null means no limit. */
|
||||
export interface ConnectorGrantLimits {
|
||||
actions: string[] | null;
|
||||
fixedArguments: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const NO_LIMITS: ConnectorGrantLimits = { actions: null, fixedArguments: null };
|
||||
|
||||
/** Which agents may use which labelled company accounts: the CEO all active ones by role, others one per toolkit by grant. */
|
||||
export class ConnectorGrantsService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
grant(input: { orgId: string; agentId: string; toolkitSlug: string; accountLabel: string | null; grantedByType: "agent" | "employee"; grantedById: string | null }): string {
|
||||
grant(input: { orgId: string; agentId: string; toolkitSlug: string; accountLabel: string | null; limits: ConnectorGrantLimits; grantedByType: "agent" | "employee"; grantedById: string | null }): string {
|
||||
const agent = this.db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'")
|
||||
.get(input.agentId, input.orgId);
|
||||
if (!agent) throw new Error("Grant target must be an active agent in this organization.");
|
||||
const slug = normalizeSlug(input.toolkitSlug);
|
||||
const label = this.chooseAccount(input.orgId, slug, input.accountLabel).label;
|
||||
const actions = input.limits.actions === null ? null : JSON.stringify(normalizeActionNames(input.limits.actions));
|
||||
const fixed = input.limits.fixedArguments === null ? null : JSON.stringify(input.limits.fixedArguments);
|
||||
this.db.prepare(`
|
||||
insert into agent_connector_grants(id, org_id, agent_id, toolkit_slug, account_label, granted_by_type, granted_by_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
insert into agent_connector_grants(id, org_id, agent_id, toolkit_slug, account_label, allowed_actions, fixed_arguments, granted_by_type, granted_by_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, agent_id, toolkit_slug) do update set
|
||||
account_label = excluded.account_label,
|
||||
allowed_actions = excluded.allowed_actions,
|
||||
fixed_arguments = excluded.fixed_arguments,
|
||||
granted_by_type = excluded.granted_by_type,
|
||||
granted_by_id = excluded.granted_by_id
|
||||
`).run(createId("cgr"), input.orgId, input.agentId, slug, label, input.grantedByType, input.grantedById, new Date().toISOString());
|
||||
`).run(createId("cgr"), input.orgId, input.agentId, slug, label, actions, fixed, input.grantedByType, input.grantedById, new Date().toISOString());
|
||||
return label;
|
||||
}
|
||||
|
||||
@@ -78,6 +92,15 @@ export class ConnectorGrantsService {
|
||||
return account;
|
||||
}
|
||||
|
||||
/** The limits this agent works under on a toolkit; the CEO has none. */
|
||||
limitsForAgent(orgId: string, agentId: string, toolkitSlug: string): ConnectorGrantLimits {
|
||||
if (this.isCeo(orgId, agentId)) return NO_LIMITS;
|
||||
const row = this.db.prepare("select allowed_actions, fixed_arguments from agent_connector_grants where org_id = ? and agent_id = ? and toolkit_slug = ?")
|
||||
.get(orgId, agentId, normalizeSlug(toolkitSlug)) as { allowed_actions: string | null; fixed_arguments: string | null } | undefined;
|
||||
if (!row) throw new Error(`You have no grant for the "${toolkitSlug}" connector. Ask the CEO to grant it to you (grant_connector).`);
|
||||
return parseLimits(row.allowed_actions, row.fixed_arguments);
|
||||
}
|
||||
|
||||
activeAccounts(orgId: string): ConnectorAccountRow[] {
|
||||
const rows = this.db.prepare(`
|
||||
select id, org_id, toolkit_slug, toolkit_name, label, status
|
||||
@@ -97,12 +120,19 @@ export class ConnectorGrantsService {
|
||||
|
||||
grantsForOrg(orgId: string): ConnectorGrantRow[] {
|
||||
const rows = this.db.prepare(`
|
||||
select id, org_id, agent_id, toolkit_slug, account_label
|
||||
select id, org_id, agent_id, toolkit_slug, account_label, allowed_actions, fixed_arguments
|
||||
from agent_connector_grants
|
||||
where org_id = ?
|
||||
order by toolkit_slug asc
|
||||
`).all(orgId) as Array<{ id: string; org_id: string; agent_id: string; toolkit_slug: string; account_label: string }>;
|
||||
return rows.map((row) => ({ id: row.id, orgId: row.org_id, agentId: row.agent_id, toolkitSlug: row.toolkit_slug, accountLabel: row.account_label }));
|
||||
`).all(orgId) as Array<{ id: string; org_id: string; agent_id: string; toolkit_slug: string; account_label: string; allowed_actions: string | null; fixed_arguments: string | null }>;
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
orgId: row.org_id,
|
||||
agentId: row.agent_id,
|
||||
toolkitSlug: row.toolkit_slug,
|
||||
accountLabel: row.account_label,
|
||||
limits: parseLimits(row.allowed_actions, row.fixed_arguments),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The named active account of a toolkit, or its only one; several accounts with no name is an error that lists them. */
|
||||
@@ -133,6 +163,46 @@ export function normalizeSlug(slug: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Why a call breaks a grant's limits, or null when it keeps them; an action that cannot take a fixed argument could reach past it, so it is refused. */
|
||||
export function grantLimitRefusal(
|
||||
limits: ConnectorGrantLimits,
|
||||
toolkit: string,
|
||||
action: { name: string; parameters: Record<string, unknown> },
|
||||
input: Record<string, unknown>,
|
||||
): string | null {
|
||||
const name = action.name.toUpperCase();
|
||||
if (limits.actions && !limits.actions.includes(name)) {
|
||||
return `Your grant for ${toolkit} allows only ${limits.actions.join(", ")}; ${name} is not among them.`;
|
||||
}
|
||||
const properties = (action.parameters.properties ?? {}) as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(limits.fixedArguments ?? {})) {
|
||||
if (!Object.hasOwn(properties, key)) return `Your grant for ${toolkit} is limited to ${key}=${JSON.stringify(value)}, and ${name} takes no ${key}.`;
|
||||
if (!Object.hasOwn(input, key)) return `Your grant for ${toolkit} is limited to ${key}=${JSON.stringify(value)}; pass ${key} with that value.`;
|
||||
if (canonicalJson(input[key]) !== canonicalJson(value)) {
|
||||
return `Your grant for ${toolkit} is limited to ${key}=${JSON.stringify(value)}; this call has ${key}=${JSON.stringify(input[key])}.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a grant's action list lets an agent see an action at all. */
|
||||
export function grantAllowsAction(limits: ConnectorGrantLimits, actionName: string): boolean {
|
||||
return limits.actions === null || limits.actions.includes(actionName.toUpperCase());
|
||||
}
|
||||
|
||||
function normalizeActionNames(actions: string[]): string[] {
|
||||
const names = actions.map((action) => action.trim().toUpperCase());
|
||||
if (names.length === 0 || names.some((name) => !name)) throw new Error("actions must name at least one action, none blank.");
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
function parseLimits(actions: string | null, fixedArguments: string | null): ConnectorGrantLimits {
|
||||
return {
|
||||
actions: actions === null ? null : JSON.parse(actions) as string[],
|
||||
fixedArguments: fixedArguments === null ? null : JSON.parse(fixedArguments) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
/** Account labels are trimmed and lower-case so "Sales" and "sales " name the same account. */
|
||||
export function normalizeAccountLabel(label: string): string {
|
||||
const normalized = label.trim().toLowerCase();
|
||||
|
||||
@@ -386,6 +386,7 @@ export const zh: Record<string, string> = {
|
||||
"Service": "服务",
|
||||
"Account label, e.g. sales": "账号标签,例如 sales",
|
||||
"Add account": "添加账号",
|
||||
"limited": "受限",
|
||||
"Refresh models": "刷新模型",
|
||||
"Region": "区域",
|
||||
"Rejected": "已驳回",
|
||||
|
||||
@@ -56,8 +56,10 @@ export function ConnectedList({
|
||||
{accounts.map((account) => {
|
||||
const holders = grants
|
||||
.filter((grant) => grant.toolkitSlug === account.toolkitSlug && grant.accountLabel === account.label)
|
||||
.map((grant) => agentNames.get(grant.agentId))
|
||||
.filter((name): name is string => Boolean(name));
|
||||
.flatMap((grant) => {
|
||||
const name = agentNames.get(grant.agentId);
|
||||
return name ? [grant.allowedActions || grant.fixedArguments ? `${name} · ${t("limited")}` : name] : [];
|
||||
});
|
||||
const busy = busySlug !== null;
|
||||
return (
|
||||
<div key={account.id} style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 14, background: "var(--panel)" }}>
|
||||
|
||||
@@ -467,6 +467,10 @@ export interface ConnectorGrantRecord {
|
||||
agentId: string;
|
||||
toolkitSlug: string;
|
||||
accountLabel: string;
|
||||
/** JSON array of the only action names this grant allows; null allows every action. */
|
||||
allowedActions: string | null;
|
||||
/** JSON object of argument values every action under this grant must take; null fixes none. */
|
||||
fixedArguments: string | null;
|
||||
grantedByType: string;
|
||||
grantedById: string | null;
|
||||
createdAt: string;
|
||||
|
||||
Reference in new issue
Block a user