diff --git a/src/main/database/migrations/084-connector-grant-limits.ts b/src/main/database/migrations/084-connector-grant-limits.ts new file mode 100644 index 00000000..e1b69ca0 --- /dev/null +++ b/src/main/database/migrations/084-connector-grant-limits.ts @@ -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')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index e1f5905b..4ac8b315 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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; diff --git a/src/main/services/agent-connector-tools.ts b/src/main/services/agent-connector-tools.ts index 9ce74eb1..78a3231c 100644 --- a/src/main/services/agent-connector-tools.ts +++ b/src/main/services/agent-connector-tools.ts @@ -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 { + 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; +} + +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, + }); +} + +/** 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("; ")})` : ""; +} diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 40f6cce4..26f70957 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -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"], }, diff --git a/src/main/services/composio/composio-tool-bridge.ts b/src/main/services/composio/composio-tool-bridge.ts index 2d8e1280..cef25d2e 100644 --- a/src/main/services/composio/composio-tool-bridge.ts +++ b/src/main/services/composio/composio-tool-bridge.ts @@ -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): Promise { +async function findActions(db: DatabaseSync, apiKey: string, orgId: string, agentId: string, toolkits: string[], args: Record): Promise { 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 : 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) { diff --git a/src/main/services/composio/connector-grants.ts b/src/main/services/composio/connector-grants.ts index c199bc38..4daedab4 100644 --- a/src/main/services/composio/connector-grants.ts +++ b/src/main/services/composio/connector-grants.ts @@ -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 | 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 }, + input: Record, +): 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; + 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, + }; +} + /** 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(); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index feb40829..ce7ece1d 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -386,6 +386,7 @@ export const zh: Record = { "Service": "服务", "Account label, e.g. sales": "账号标签,例如 sales", "Add account": "添加账号", + "limited": "受限", "Refresh models": "刷新模型", "Region": "区域", "Rejected": "已驳回", diff --git a/src/renderer/src/pages/connectors/ConnectedList.tsx b/src/renderer/src/pages/connectors/ConnectedList.tsx index 3c557b22..af7909de 100644 --- a/src/renderer/src/pages/connectors/ConnectedList.tsx +++ b/src/renderer/src/pages/connectors/ConnectedList.tsx @@ -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 (
diff --git a/src/shared/domain.ts b/src/shared/domain.ts index eae8b418..5ff04b54 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -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; diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts index 4d71309a..11c928be 100644 --- a/tests/unit/agent-instructions.test.ts +++ b/tests/unit/agent-instructions.test.ts @@ -158,7 +158,7 @@ describe("agent instruction wiring", () => { values ('ca_github', ?, 'github', 'GitHub', 'main', 'ac_github', 'ACTIVE', ?, ?) `).run(orgId, now, now); new ConnectorGrantsService(connection.db).grant({ - orgId, agentId, toolkitSlug: "github", accountLabel: null, grantedByType: "employee", grantedById: null, + orgId, agentId, toolkitSlug: "github", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById: null, }); taskId = organization.createTask({ orgId, title: "Ship it", description: "d", status: TASK_STATUS.IN_PROGRESS, diff --git a/tests/unit/composio-reconnect.test.ts b/tests/unit/composio-reconnect.test.ts index f70e75c3..018c7003 100644 --- a/tests/unit/composio-reconnect.test.ts +++ b/tests/unit/composio-reconnect.test.ts @@ -58,7 +58,7 @@ async function connectedCompany() { const first = await service.connectToolkit(orgId, "github", "main"); remote.get(first.accountId)!.status = "ACTIVE"; await service.refreshAccounts(orgId); - new ConnectorGrantsService(connection.db).grant({ orgId, agentId: ceoId, toolkitSlug: "github", accountLabel: null, grantedByType: "agent", grantedById: ceoId }); + new ConnectorGrantsService(connection.db).grant({ orgId, agentId: ceoId, toolkitSlug: "github", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "agent", grantedById: ceoId }); const account = () => connection.db.prepare("select id, status, pending_account_id as pending from connector_accounts where org_id = ?").get(orgId) as { id: string; status: string; pending: string | null }; const grants = () => (connection.db.prepare("select count(*) as n from agent_connector_grants where org_id = ?").get(orgId) as { n: number }).n; return { connection, orgId, service, oldId: first.accountId, account, grants }; diff --git a/tests/unit/composio-tool-pages.test.ts b/tests/unit/composio-tool-pages.test.ts index 1d5cf78e..e7361d80 100644 --- a/tests/unit/composio-tool-pages.test.ts +++ b/tests/unit/composio-tool-pages.test.ts @@ -175,13 +175,42 @@ describe("agents reach every action through search and run", () => { h.connection.close(); }); + test("a grant limited to some actions and one resource shows and runs only those", async () => { + const withRepo = (slug: string, description: string) => ({ slug, name: slug, description, input_parameters: { type: "object", properties: { repo: { type: "string" } } }, toolkit: { slug: "github" } }); + pages.set("github:", { items: [withRepo("GITHUB_LIST_ISSUES", "list issues"), withRepo("GITHUB_CREATE_ISSUE", "create issue"), tool("GITHUB_LIST_ALL_REPOS")], next_cursor: null }); + const h = company("ak_limits"); + const staff = h.connection.transaction(() => { + const providerId = (h.connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(h.orgId) as { id: string }).id; + const id = new OrganizationService(h.connection.db).createAgent({ orgId: h.orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: h.ceoId }); + new ConnectorGrantsService(h.connection.db).grant({ orgId: h.orgId, agentId: id, toolkitSlug: "github", accountLabel: null, limits: { actions: ["github_list_issues", "GITHUB_LIST_ALL_REPOS"], fixedArguments: { repo: "site" } }, grantedByType: "agent", grantedById: h.ceoId }); + return id; + }); + const asAda = (name: string, args: Record) => executeComposioTool(h.connection.db, h.keys, h.orgId, staff, { id: "c9", name, arguments: args }); + const found = JSON.parse(await asAda("composio_find_actions", { query: "issue" })) as { matches: Array<{ action: string }> }; + expect(found.matches.map((match) => match.action)).toEqual(["GITHUB_LIST_ISSUES"]); + expect(await asAda("composio_run_action", { action: "GITHUB_CREATE_ISSUE", arguments: { repo: "site" } })) + .toBe("ERROR: Your grant for github allows only GITHUB_LIST_ISSUES, GITHUB_LIST_ALL_REPOS; GITHUB_CREATE_ISSUE is not among them."); + expect(await asAda("composio_run_action", { action: "GITHUB_LIST_ISSUES", arguments: { repo: "secret" } })) + .toBe('ERROR: Your grant for github is limited to repo="site"; this call has repo="secret".'); + expect(await asAda("composio_run_action", { action: "GITHUB_LIST_ISSUES", arguments: {} })) + .toBe('ERROR: Your grant for github is limited to repo="site"; pass repo with that value.'); + expect(await asAda("composio_run_action", { action: "GITHUB_LIST_ALL_REPOS", arguments: {} })) + .toBe('ERROR: Your grant for github is limited to repo="site", and GITHUB_LIST_ALL_REPOS takes no repo.'); + const sent = written.length; + expect(await asAda("composio_run_action", { action: "GITHUB_LIST_ISSUES", arguments: { repo: "site" } })).toBe(JSON.stringify({ number: 42 })); + expect(written.length).toBe(sent + 1); + // The CEO holds every action by role. + expect(await h.call("composio_run_action", { action: "GITHUB_CREATE_ISSUE", arguments: { repo: "other" } })).toBe(JSON.stringify({ number: 42 })); + h.connection.close(); + }); + test("a grant revoked while the catalog loads stops the action, and a search is not journaled as work", async () => { pages.set("github:", { items: [tool("GITHUB_STAR_A_REPOSITORY")], next_cursor: null }); const h = company("ak_revoke"); const staff = h.connection.transaction(() => { const providerId = (h.connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(h.orgId) as { id: string }).id; const id = new OrganizationService(h.connection.db).createAgent({ orgId: h.orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: h.ceoId }); - new ConnectorGrantsService(h.connection.db).grant({ orgId: h.orgId, agentId: id, toolkitSlug: "github", accountLabel: null, grantedByType: "agent", grantedById: h.ceoId }); + new ConnectorGrantsService(h.connection.db).grant({ orgId: h.orgId, agentId: id, toolkitSlug: "github", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "agent", grantedById: h.ceoId }); return id; }); const running = executeComposioTool(h.connection.db, h.keys, h.orgId, staff, { id: "c2", name: "composio_run_action", arguments: { action: "GITHUB_STAR_A_REPOSITORY", arguments: {} } }); diff --git a/tests/unit/connector-grants.test.ts b/tests/unit/connector-grants.test.ts index 6d61afee..8bd8d8b0 100644 --- a/tests/unit/connector-grants.test.ts +++ b/tests/unit/connector-grants.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; import { ChatService } from "../../src/main/services/chat-service"; -import { ConnectorGrantsService } from "../../src/main/services/composio/connector-grants"; +import { ConnectorGrantsService, grantLimitRefusal } from "../../src/main/services/composio/connector-grants"; import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; function call(name: string, args: Record) { @@ -71,7 +71,7 @@ describe("connector grants", () => { const h = harness(); const grants = new ConnectorGrantsService(h.connection.db); expect(() => grants.grant({ - orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", accountLabel: null, grantedByType: "employee", grantedById: null, + orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById: null, })).toThrow(/No active connection/); }); @@ -122,6 +122,29 @@ describe("connector grants", () => { expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual(["github"]); }); + test("the CEO grants a toolkit narrowed to some actions and argument values", () => { + const h = harness(); + connectToolkit(h, "gmail", "Gmail"); + expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "gmail", actions: ["gmail_fetch_emails"], fixed_arguments: { label: "support" } }))) + .toContain('through its "main" account (only GMAIL_FETCH_EMAILS; limited to {"label":"support"})'); + expect(dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {}))) + .toContain('Gmail (gmail), account "main": granted to Dana (only GMAIL_FETCH_EMAILS; limited to {"label":"support"})'); + expect(new ConnectorGrantsService(h.connection.db).limitsForAgent(h.orgId, h.danaId, "gmail")).toEqual({ actions: ["GMAIL_FETCH_EMAILS"], fixedArguments: { label: "support" } }); + expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "gmail", actions: [] }))) + .toBe("ERROR: actions must name at least one action, none blank."); + expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "gmail", fixed_arguments: ["label"] }))) + .toBe('ERROR: "fixed_arguments" must be an object of argument values.'); + const nullRepo = { actions: null, fixedArguments: { repo: null } }; + const action = { name: "GITHUB_LIST_ISSUES", parameters: { properties: { repo: {} } } }; + expect(grantLimitRefusal(nullRepo, "github", action, {})).toBe("Your grant for github is limited to repo=null; pass repo with that value."); + expect(grantLimitRefusal(nullRepo, "github", action, { repo: null })).toBeNull(); + const proto = { actions: null, fixedArguments: JSON.parse('{"__proto__": {}}') as Record }; + expect(grantLimitRefusal(proto, "github", action, {})).toBe("Your grant for github is limited to __proto__={}, and GITHUB_LIST_ISSUES takes no __proto__."); + // A grant without limits replaces a limited one. + dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "gmail" })); + expect(new ConnectorGrantsService(h.connection.db).limitsForAgent(h.orgId, h.danaId, "gmail")).toEqual({ actions: null, fixedArguments: null }); + }); + test("staff cannot hand out connector access", () => { const h = harness(); connectToolkit(h, "github", "GitHub"); @@ -134,7 +157,7 @@ describe("connector grants", () => { const h = harness(); connectToolkit(h, "slack", "Slack"); const grants = new ConnectorGrantsService(h.connection.db); - grants.grant({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "slack", accountLabel: null, grantedByType: "employee", grantedById: null }); + grants.grant({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "slack", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById: null }); expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual(["slack"]); h.connection.db.prepare("update connector_accounts set status = 'EXPIRED' where org_id = ? and toolkit_slug = 'slack'").run(h.orgId); expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 3b69ff45..0ff8d667 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84]); connection.close(); });