diff --git a/apps/sim/lib/core/application/delegated-api-scopes.ts b/apps/sim/lib/core/application/delegated-api-scopes.ts new file mode 100644 index 00000000000..e279bc0971f --- /dev/null +++ b/apps/sim/lib/core/application/delegated-api-scopes.ts @@ -0,0 +1,18 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { OAuthApiScope } from '@/lib/auth/oauth-provider' + +const grantedScopes = new AsyncLocalStorage() + +/** + * Runs `execute` with every delegated principal it authorizes holding only `scopes`, the way an + * OAuth token's grant bounds its user: `requireOAuthOperationScope` refuses an operation whose + * declared scope they do not satisfy, whatever role the principal's subject holds. + */ +export function withDelegatedApiScopes(scopes: readonly OAuthApiScope[], execute: () => T): T { + return grantedScopes.run(Object.freeze([...scopes]), execute) +} + +/** The scopes the current invocation grants its delegated principals, or none when unbounded. */ +export function delegatedApiScopes(): readonly OAuthApiScope[] | undefined { + return grantedScopes.getStore() +} diff --git a/apps/sim/lib/core/application/oauth-authorization.ts b/apps/sim/lib/core/application/oauth-authorization.ts index 453246db111..e131deb9573 100644 --- a/apps/sim/lib/core/application/oauth-authorization.ts +++ b/apps/sim/lib/core/application/oauth-authorization.ts @@ -1,5 +1,6 @@ import type { Principal } from '@sim/auth/principal' import { type OAuthApiScope, oauthScopeSatisfies } from '@/lib/auth/oauth-provider' +import { delegatedApiScopes } from '@/lib/core/application/delegated-api-scopes' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import type { ApplicationOperation } from '@/lib/core/application/operation' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -18,17 +19,46 @@ export class OAuthAccessTokenExpiredError extends OrchestrationError { } } -/** Enforces the client's grant independently of workspace membership and HTTP method. */ +/** + * Enforces the client's grant independently of workspace membership and HTTP method: an OAuth + * token's scopes, or those {@link withDelegatedApiScopes} grants a delegated principal. A + * delegated principal may still reach an operation that declares no scope, which is internal. + */ export function requireOAuthOperationScope( principal: Principal, operation: ApplicationOperation ): void { - if (principal.kind !== 'oauth_access_token') return - if (principal.expiresAt.getTime() <= Date.now()) throw new OAuthAccessTokenExpiredError() - if (!operation.oauthScope) { - throw new Error(`Operation ${operation.id} has no OAuth scope policy`) + const granted = grantedApiScopes(principal) + if (!granted) return + if (principal.kind === 'oauth_access_token') { + if (principal.expiresAt.getTime() <= Date.now()) throw new OAuthAccessTokenExpiredError() + if (!operation.oauthScope) { + throw new Error(`Operation ${operation.id} has no OAuth scope policy`) + } } - if (!oauthScopeSatisfies(principal.scopes, operation.oauthScope)) { + if (operation.oauthScope && !oauthScopeSatisfies(granted, operation.oauthScope)) { throw new InsufficientScopeError(operation.oauthScope) } } + +/** + * Holds a change no scoped operation guards — an internal operation, or one authorized outside the + * operation layer — to the current {@link withDelegatedApiScopes} grant. Outside a grant it allows. + */ +export function requireDelegatedApiScope(scope: OAuthApiScope): void { + const granted = delegatedApiScopes() + if (granted && !oauthScopeSatisfies(granted, scope)) throw new InsufficientScopeError(scope) +} + +function grantedApiScopes(principal: Principal): readonly string[] | undefined { + switch (principal.kind) { + case 'oauth_access_token': + return principal.scopes + case 'delegated': + case 'resource_delegated': + case 'organization_delegated': + return delegatedApiScopes() + default: + return undefined + } +} diff --git a/apps/sim/lib/mothership/agent-cli/index.ts b/apps/sim/lib/mothership/agent-cli/index.ts index 867223594fa..a9d16d0c39c 100644 --- a/apps/sim/lib/mothership/agent-cli/index.ts +++ b/apps/sim/lib/mothership/agent-cli/index.ts @@ -1,4 +1,6 @@ +import { OAUTH_API_READ_SCOPE } from '@/lib/auth/oauth-provider' import { countWithheldContent } from '@/lib/core/application' +import { withDelegatedApiScopes } from '@/lib/core/application/delegated-api-scopes' import { executeFileCopyCliRequest } from '@/lib/mothership/agent-cli/file-copy' import { isReadOnlyCliRequest } from '@/lib/mothership/agent-cli/read-only' import { executeAgentCliService } from '@/lib/mothership/agent-cli/services' @@ -10,6 +12,8 @@ import type { ServerToolContext } from '@/lib/mothership/tools/server/base-tool' export interface AgentCliExecutionContext extends Omit { readOnly?: boolean + /** Null when the run has no caller of its own, as for an unattributed inbox message. */ + secretActorUserId?: string | null signal?: AbortSignal } @@ -18,8 +22,12 @@ export async function executeAgentCliRequest( request: AgentCliRequest, context: AgentCliExecutionContext ): Promise { + const execute = () => executeUncountedAgentCliRequest(request, context) + // Commands authorize the subject's live role; a callerless inbox run's subject is the owner. const { result, withheld } = await countWithheldContent(() => - executeUncountedAgentCliRequest(request, context) + context.secretActorUserId === null + ? withDelegatedApiScopes([OAUTH_API_READ_SCOPE], execute) + : execute() ) const note = describeWithheldContent(withheld) if (!note) return result diff --git a/apps/sim/lib/mothership/agent-cli/project-file-read.integration.ts b/apps/sim/lib/mothership/agent-cli/project-file-read.integration.ts index f22b4adc8e1..d2e0293bcb3 100644 --- a/apps/sim/lib/mothership/agent-cli/project-file-read.integration.ts +++ b/apps/sim/lib/mothership/agent-cli/project-file-read.integration.ts @@ -7,6 +7,7 @@ import { permissions, user, userStats, + workflow, workspace, workspaceFileSearchBuild, workspaceFileSearchChunk, @@ -21,7 +22,7 @@ import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { Document, Packer, Paragraph } from 'docx' -import { eq, inArray, or } from 'drizzle-orm' +import { and, eq, inArray, or } from 'drizzle-orm' import sharp from 'sharp' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -42,8 +43,13 @@ import { encryptSecret } from '@/lib/core/security/encryption' import { type AgentCliExecutionContext, executeAgentCliRequest } from '@/lib/mothership/agent-cli' import { createCopilotResourceAdmission } from '@/lib/mothership/auth/application-delegation' import { createRegistryContentGate } from '@/lib/mothership/content-delivery' -import type { AgentCliAugmentationInvocation } from '@/lib/mothership/generated/agent-cli' +import { + type AgentCliAugmentationInvocation, + AgentCliRawResult, +} from '@/lib/mothership/generated/agent-cli' import { projectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' +import type { ToolExecutionContext } from '@/lib/mothership/tool-executor/types' +import { executeSimCli } from '@/lib/mothership/tools/handlers/sim-cli' import { createProjectFile } from '@/lib/projects/files/application' import { storeCompiledDoc } from '@/lib/uploads/documents/compiled-store' import { fileDocumentInputIdentity } from '@/lib/uploads/documents/input-identity' @@ -54,6 +60,7 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr const storageRoot = mkdtempSync(join(tmpdir(), 'sim-project-cli-reads-')) setUploadDirServer(storageRoot) const fixtures: { userId: string; workspaceId: string; projectId: string }[] = [] +const memberIds: string[] = [] const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = [] @@ -81,39 +88,32 @@ function check(name: string, run: () => Promise, requiresRedis = false) { }) } -async function fixture() { +async function insertUser(name: string) { const userId = generateId() - const workspaceId = generateId() await db.insert(user).values({ id: userId, email: `${userId}@project-read.invalid`, - name: 'File reader', + name, emailVerified: true, createdAt: new Date(), updatedAt: new Date(), }) await db.insert(userStats).values({ id: generateId(), userId }) - await insertWorkspaceFixture(db, { - id: workspaceId, - ownerId: userId, - billedAccountUserId: userId, - name: 'Read environment', - }) - await db.insert(permissions).values({ + return userId +} + +function grantWorkspace(userId: string, workspaceId: string, permissionType: 'admin' | 'read') { + return db.insert(permissions).values({ id: generateId(), userId, entityType: 'workspace', entityId: workspaceId, - permissionType: 'admin', + permissionType, }) - const [binding] = await db - .select({ projectId: workspace.projectId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - if (!binding?.projectId) throw new Error('Project fixture missing') - const ids = { userId, workspaceId, projectId: binding.projectId } - fixtures.push(ids) - const context: AgentCliExecutionContext = { +} + +function cliContext(userId: string, workspaceId: string): AgentCliExecutionContext { + return { userId, workspaceId, requestMode: 'agent', @@ -125,6 +125,26 @@ async function fixture() { }), resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([], { userId, workspaceId }), } +} + +async function fixture() { + const userId = await insertUser('File reader') + const workspaceId = generateId() + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId: userId, + billedAccountUserId: userId, + name: 'Read environment', + }) + await grantWorkspace(userId, workspaceId, 'admin') + const [binding] = await db + .select({ projectId: workspace.projectId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + if (!binding?.projectId) throw new Error('Project fixture missing') + const ids = { userId, workspaceId, projectId: binding.projectId } + fixtures.push(ids) + const context = cliContext(userId, workspaceId) const principal = createSessionPrincipal({ userId }) return { ...ids, context, principal } } @@ -272,6 +292,137 @@ check('benchmark reference generation cannot create Project files', async () => expect(await stored()).toHaveLength(1) }) +function simCli( + argv: string[], + context: ToolExecutionContext, + fileOwner?: { entityType: 'project'; entityId: string } +) { + return executeSimCli( + { request: { ...(fileOwner ? { fileOwner } : {}), invocation: { kind: 'cli', argv } } }, + context + ) +} + +check('sim_cli for a callerless run holds only the read API scopes', async () => { + const f = await fixture() + const name = `capped-${generateId()}` + const ownerFallback = { ...f.context, workflowId: '', secretActorUserId: null } + // In-band calls carry no `userPermission`, only the run's recorded callerless authority. + const contexts: ToolExecutionContext[] = [ + { ...ownerFallback, userPermission: 'read' }, + ownerFallback, + ] + const project = { entityType: 'project', entityId: f.projectId } as const + const writes: Array<[string[], typeof project | undefined]> = [ + [['workflows', 'create', '--name', name], undefined], + [['projects', 'files', 'create', f.projectId, '--name', name, '--content', 'x'], project], + [['secrets', 'set', 'CAPPED', '--scope', 'personal', '--value', 'v'], undefined], + ] + const workflows = () => + db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, f.workspaceId), eq(workflow.name, name))) + const projectFiles = () => + db + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(eq(workspaceFiles.originalName, name)) + + for (const context of contexts) { + for (const [argv, owner] of writes) { + const refused = await simCli(argv, context, owner) + expect(refused.success).toBe(false) + expect(AgentCliRawResult.parse(refused.output).stderr).toContain('api:write') + } + const read = await simCli(['workflows', 'list', '--output', 'json'], context) + expect(read.success, read.error).toBe(true) + } + expect(await workflows()).toEqual([]) + expect(await projectFiles()).toEqual([]) + + const attributed: ToolExecutionContext = { + ...f.context, + workflowId: '', + userPermission: 'admin', + secretActorUserId: f.userId, + } + for (const [argv, owner] of writes) { + const written = await simCli(argv, attributed, owner) + expect(written.success, written.error).toBe(true) + } + expect(await workflows()).toHaveLength(1) + expect(await projectFiles()).toHaveLength(1) +}) + +check('sim_cli services for a callerless run refuse the writes they route', async () => { + const f = await fixture() + const service = (name: string, input: Record, context: ToolExecutionContext) => + executeSimCli({ request: { invocation: { kind: 'service', name, input } } }, context) + const inboxSecretScope = async () => + ( + await db + .select({ scope: workspace.inboxSecretScope }) + .from(workspace) + .where(eq(workspace.id, f.workspaceId)) + )[0]?.scope + const before = await inboxSecretScope() + const widen = before === 'all' ? 'selected' : 'all' + const writes: Array<[string, Record]> = [ + [ + 'settings', + { scope: 'workspace', action: 'update', section: 'inbox', changes: { secretScope: widen } }, + ], + ] + + const callerless: ToolExecutionContext = { ...f.context, workflowId: '', secretActorUserId: null } + for (const [name, input] of writes) { + const refused = await service(name, input, callerless) + expect(refused.success).toBe(false) + expect(refused.error).toContain('api:write') + } + expect(await inboxSecretScope()).toBe(before) + const read = await service( + 'settings', + { scope: 'workspace', action: 'get', section: 'inbox' }, + callerless + ) + expect(read.success, read.error).toBe(true) + + const attributed: ToolExecutionContext = { + ...f.context, + workflowId: '', + userPermission: 'admin', + secretActorUserId: f.userId, + } + for (const [name, input] of writes) { + const written = await service(name, input, attributed) + expect(written.success, `${name}: ${written.error}`).toBe(true) + } + expect(await inboxSecretScope()).toBe(widen) +}) + +check('sim_cli for a read-only member keeps what that role allows', async () => { + const f = await fixture() + const memberId = await insertUser('Read member') + memberIds.push(memberId) + await grantWorkspace(memberId, f.workspaceId, 'read') + const member: ToolExecutionContext = { + ...cliContext(memberId, f.workspaceId), + workflowId: '', + userPermission: 'read', + } + + const personalSecret = await simCli( + ['secrets', 'set', 'MEMBER', '--scope', 'personal', '--value', 'v'], + member + ) + expect(personalSecret.success, personalSecret.error).toBe(true) + const workflowCreate = await simCli(['workflows', 'create', '--name', 'x'], member) + expect(workflowCreate.success).toBe(false) + expect(AgentCliRawResult.parse(workflowCreate.output).stderr).not.toContain('api:write') +}) + check('Project grep matches public bytes while excluding classified secret text', async () => { const f = await fixture() const secret = 'synthetic-project-grep-canary' @@ -771,6 +922,7 @@ afterAll(async () => { await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) await db.delete(user).where(eq(user.id, f.userId)) } + if (memberIds.length) await db.delete(user).where(inArray(user.id, memberIds)) await rm(storageRoot, { recursive: true, force: true }) const report = process.env.PROJECT_FILE_READ_REPORT_PATH ?? 'test-results/project-file-read.json' await mkdir(dirname(report), { recursive: true }) diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts index f188468b19f..25c43f1eac4 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts @@ -53,6 +53,7 @@ export async function executeSimCli( messageId: context.messageId, parentToolCallId: context.parentToolCallId, userPermission: context.userPermission, + secretActorUserId: context.secretActorUserId, resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, readOnly: context.benchmark === 'distill', }) diff --git a/apps/sim/lib/mothership/tools/server/base-tool.ts b/apps/sim/lib/mothership/tools/server/base-tool.ts index 146fd76afbe..7bf89cf7f01 100644 --- a/apps/sim/lib/mothership/tools/server/base-tool.ts +++ b/apps/sim/lib/mothership/tools/server/base-tool.ts @@ -68,4 +68,6 @@ export interface BaseServerTool { inputSchema?: z.ZodType /** Optional Zod schema for output validation */ outputSchema?: z.ZodType + /** Whether these validated arguments change stored data; a delegated read grant refuses them. */ + writes?(args: TArgs): boolean } diff --git a/apps/sim/lib/mothership/tools/server/dashboards.ts b/apps/sim/lib/mothership/tools/server/dashboards.ts index 61c53d14238..09459f2fb28 100644 --- a/apps/sim/lib/mothership/tools/server/dashboards.ts +++ b/apps/sim/lib/mothership/tools/server/dashboards.ts @@ -24,6 +24,7 @@ function workspaceFor(input: { workspaceId?: string }, context?: ServerToolConte export const dashboardsServerTool: BaseServerTool = { name: 'dashboards', inputSchema: mothershipDashboardsInputSchema, + writes: (args) => mothershipDashboardsInputSchema.parse(args).action === 'set', async execute(raw, context) { const input = mothershipDashboardsInputSchema.parse(raw) const workspaceId = workspaceFor(input, context) diff --git a/apps/sim/lib/mothership/tools/server/router.ts b/apps/sim/lib/mothership/tools/server/router.ts index eafccac8e7c..918d2c300e2 100644 --- a/apps/sim/lib/mothership/tools/server/router.ts +++ b/apps/sim/lib/mothership/tools/server/router.ts @@ -2,6 +2,8 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' import { getValidationErrorMessage, isZodError } from '@/lib/api/server/validation' +import { OAUTH_API_WRITE_SCOPE } from '@/lib/auth/oauth-provider' +import { requireDelegatedApiScope } from '@/lib/core/application/oauth-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { Ffmpeg, @@ -156,6 +158,7 @@ export async function routeExecution( } assertServerToolNotAborted(context, `User stop signal aborted ${toolName} after validation`) + if (tool.writes?.(args)) requireDelegatedApiScope(OAUTH_API_WRITE_SCOPE) // Execute. None of the remaining tools resolve blocks or gate discovery, so the old // custom-block-overlay / block-visibility ALS scopes are gone with the tools that diff --git a/apps/sim/lib/mothership/tools/server/search-sources.ts b/apps/sim/lib/mothership/tools/server/search-sources.ts index fef87232f34..08bc83ee822 100644 --- a/apps/sim/lib/mothership/tools/server/search-sources.ts +++ b/apps/sim/lib/mothership/tools/server/search-sources.ts @@ -33,6 +33,7 @@ export const organizationSearchSourcesServerTool: BaseServerTool< name: 'search_sources', inputSchema: organizationSearchSourcesInputSchema, outputSchema: organizationSearchSourcesOutputSchema, + writes: (args) => args.action === 'approve', async execute(args, context) { if (context?.requestMode !== 'agent' && context?.requestMode !== 'plan') throw new OrchestrationError('forbidden', 'Search source controls require Mothership') diff --git a/apps/sim/lib/mothership/tools/server/settings.ts b/apps/sim/lib/mothership/tools/server/settings.ts index a769a8bb7d2..ccd509b7189 100644 --- a/apps/sim/lib/mothership/tools/server/settings.ts +++ b/apps/sim/lib/mothership/tools/server/settings.ts @@ -356,6 +356,10 @@ function settingsRefresh(context: SettingsContext, section: string): ResourceCha export const settingsServerTool: BaseServerTool = { name: 'settings', inputSchema: mothershipSettingsInputSchema, + writes: (input) => + input.action === 'update' || + (input.action === 'execute' && + settingsOperations[`${input.scope}/${input.section}`]?.[input.operation]?.effect === 'write'), async execute(raw, context) { const input = mothershipSettingsInputSchema.parse(raw) const target = await resolveSettingsContext(input.scope, context, input.workspaceId) diff --git a/apps/sim/lib/mothership/tools/server/tests.ts b/apps/sim/lib/mothership/tools/server/tests.ts index 531836f9100..5c7c0007b5c 100644 --- a/apps/sim/lib/mothership/tools/server/tests.ts +++ b/apps/sim/lib/mothership/tools/server/tests.ts @@ -46,9 +46,12 @@ function failures(report: TestReport | null) { }) } +const TEST_READ_ACTIONS = new Set(['list', 'get']) + export const testsServerTool: BaseServerTool = { name: 'tests', inputSchema: mothershipTestsInputSchema, + writes: (args) => !TEST_READ_ACTIONS.has(mothershipTestsInputSchema.parse(args).action), async execute(raw, context) { const input = mothershipTestsInputSchema.parse(raw) const workspaceId = workspaceFor(input, context) diff --git a/apps/sim/lib/mothership/tools/server/workspaces.ts b/apps/sim/lib/mothership/tools/server/workspaces.ts index 2ae7ba76097..6bfb74291e3 100644 --- a/apps/sim/lib/mothership/tools/server/workspaces.ts +++ b/apps/sim/lib/mothership/tools/server/workspaces.ts @@ -8,6 +8,7 @@ import { createOrganizationWorkspace } from '@/lib/workspaces/application/create export const workspacesServerTool: BaseServerTool = { name: 'workspaces', inputSchema: mothershipWorkspacesInputSchema, + writes: () => true, async execute(raw, context) { try { const { action: _action, ...input } = mothershipWorkspacesInputSchema.parse(raw)