From 4fe6118838ee219a78a057bbf8d7a190d5800ced Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 24 Oct 2026 11:49:28 -0700 Subject: [PATCH 1/3] fix(mothership): hold callerless runs' sim_cli to read API scopes An unattributed inbox message runs as the workspace owner with its tool permission capped to read, but sim_cli has no catalog permission and its commands authorize the delegated subject's live role, so the CLI could create, run, and delete with the owner's authority. A run with no caller of its own now executes the CLI inside a delegated API-scope grant of api:read, enforced in requireOAuthOperationScope the same way an OAuth token's scopes are, so every authorization path the CLI reaches refuses api:write operations. --- .../core/application/delegated-api-scopes.ts | 18 +++ .../core/application/oauth-authorization.ts | 33 +++- apps/sim/lib/mothership/agent-cli/index.ts | 10 +- .../project-file-read.integration.ts | 142 +++++++++++++++--- .../lib/mothership/tools/handlers/sim-cli.ts | 1 + 5 files changed, 176 insertions(+), 28 deletions(-) create mode 100644 apps/sim/lib/core/application/delegated-api-scopes.ts 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..09ae9ad54f7 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,37 @@ 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) } } + +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..becea48189f 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 run's subject is the workspace owner. const { result, withheld } = await countWithheldContent(() => - executeUncountedAgentCliRequest(request, context) + context.secretActorUserId === null && !context.organizationId + ? 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..74da8883232 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,85 @@ 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' } + 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 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 +870,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', }) From a8312c0396438a14facae1efc05757c207c41cd4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 24 Oct 2026 12:04:58 -0700 Subject: [PATCH 2/3] fix(mothership): refuse callerless service writes and organization-scoped CLI writes Mixed server tools reached through sim_cli services (settings, dashboards, tests, search_sources, workspaces) write through internal operations that declare no API scope, so the read grant never bound them. Each tool now declares which validated arguments write, and the router holds those to the current delegated grant. The grant also applies to callerless organization-scoped runs. --- .../core/application/oauth-authorization.ts | 9 ++++ apps/sim/lib/mothership/agent-cli/index.ts | 4 +- .../project-file-read.integration.ts | 42 +++++++++++++++++++ .../lib/mothership/tools/server/base-tool.ts | 2 + .../lib/mothership/tools/server/dashboards.ts | 1 + .../sim/lib/mothership/tools/server/router.ts | 3 ++ .../mothership/tools/server/search-sources.ts | 1 + .../lib/mothership/tools/server/settings.ts | 4 ++ apps/sim/lib/mothership/tools/server/tests.ts | 3 ++ .../lib/mothership/tools/server/workspaces.ts | 1 + 10 files changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/core/application/oauth-authorization.ts b/apps/sim/lib/core/application/oauth-authorization.ts index 09ae9ad54f7..e131deb9573 100644 --- a/apps/sim/lib/core/application/oauth-authorization.ts +++ b/apps/sim/lib/core/application/oauth-authorization.ts @@ -41,6 +41,15 @@ export function requireOAuthOperationScope( } } +/** + * 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': diff --git a/apps/sim/lib/mothership/agent-cli/index.ts b/apps/sim/lib/mothership/agent-cli/index.ts index becea48189f..a9d16d0c39c 100644 --- a/apps/sim/lib/mothership/agent-cli/index.ts +++ b/apps/sim/lib/mothership/agent-cli/index.ts @@ -23,9 +23,9 @@ export async function executeAgentCliRequest( context: AgentCliExecutionContext ): Promise { const execute = () => executeUncountedAgentCliRequest(request, context) - // Commands authorize the subject's live role; a callerless run's subject is the workspace owner. + // Commands authorize the subject's live role; a callerless inbox run's subject is the owner. const { result, withheld } = await countWithheldContent(() => - context.secretActorUserId === null && !context.organizationId + context.secretActorUserId === null ? withDelegatedApiScopes([OAUTH_API_READ_SCOPE], execute) : execute() ) 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 74da8883232..25c5f075646 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 @@ -350,6 +350,48 @@ check('sim_cli for a callerless run holds only the read API scopes', async () => 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' } + 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') 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) From d05552d4b2ff3087eb3a5ace692486ecbe654718 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 24 Oct 2026 12:13:53 -0700 Subject: [PATCH 3/3] test(mothership): model attributed CLI runs with their secret actor --- .../agent-cli/project-file-read.integration.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 25c5f075646..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 @@ -341,7 +341,12 @@ check('sim_cli for a callerless run holds only the read API scopes', async () => expect(await workflows()).toEqual([]) expect(await projectFiles()).toEqual([]) - const attributed: ToolExecutionContext = { ...f.context, workflowId: '', userPermission: 'admin' } + 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) @@ -384,7 +389,12 @@ check('sim_cli services for a callerless run refuse the writes they route', asyn ) expect(read.success, read.error).toBe(true) - const attributed: ToolExecutionContext = { ...f.context, workflowId: '', userPermission: 'admin' } + 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)