77 permissions ,
88 user ,
99 userStats ,
10+ workflow ,
1011 workspace ,
1112 workspaceFileSearchBuild ,
1213 workspaceFileSearchChunk ,
@@ -21,7 +22,7 @@ import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads
2122import { getErrorMessage } from '@sim/utils/errors'
2223import { generateId } from '@sim/utils/id'
2324import { Document , Packer , Paragraph } from 'docx'
24- import { eq , inArray , or } from 'drizzle-orm'
25+ import { and , eq , inArray , or } from 'drizzle-orm'
2526import sharp from 'sharp'
2627import { afterAll , beforeEach , describe , expect , it , vi } from 'vitest'
2728
@@ -42,8 +43,13 @@ import { encryptSecret } from '@/lib/core/security/encryption'
4243import { type AgentCliExecutionContext , executeAgentCliRequest } from '@/lib/mothership/agent-cli'
4344import { createCopilotResourceAdmission } from '@/lib/mothership/auth/application-delegation'
4445import { createRegistryContentGate } from '@/lib/mothership/content-delivery'
45- import type { AgentCliAugmentationInvocation } from '@/lib/mothership/generated/agent-cli'
46+ import {
47+ type AgentCliAugmentationInvocation ,
48+ AgentCliRawResult ,
49+ } from '@/lib/mothership/generated/agent-cli'
4650import { projectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result'
51+ import type { ToolExecutionContext } from '@/lib/mothership/tool-executor/types'
52+ import { executeSimCli } from '@/lib/mothership/tools/handlers/sim-cli'
4753import { createProjectFile } from '@/lib/projects/files/application'
4854import { storeCompiledDoc } from '@/lib/uploads/documents/compiled-store'
4955import { fileDocumentInputIdentity } from '@/lib/uploads/documents/input-identity'
@@ -54,6 +60,7 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr
5460const storageRoot = mkdtempSync ( join ( tmpdir ( ) , 'sim-project-cli-reads-' ) )
5561setUploadDirServer ( storageRoot )
5662const fixtures : { userId : string ; workspaceId : string ; projectId : string } [ ] = [ ]
63+ const memberIds : string [ ] = [ ]
5764const checks : { name : string ; status : 'passed' | 'failed' ; durationMs : number ; error ?: string } [ ] =
5865 [ ]
5966
@@ -81,39 +88,32 @@ function check(name: string, run: () => Promise<void>, requiresRedis = false) {
8188 } )
8289}
8390
84- async function fixture ( ) {
91+ async function insertUser ( name : string ) {
8592 const userId = generateId ( )
86- const workspaceId = generateId ( )
8793 await db . insert ( user ) . values ( {
8894 id : userId ,
8995 email : `${ userId } @project-read.invalid` ,
90- name : 'File reader' ,
96+ name,
9197 emailVerified : true ,
9298 createdAt : new Date ( ) ,
9399 updatedAt : new Date ( ) ,
94100 } )
95101 await db . insert ( userStats ) . values ( { id : generateId ( ) , userId } )
96- await insertWorkspaceFixture ( db , {
97- id : workspaceId ,
98- ownerId : userId ,
99- billedAccountUserId : userId ,
100- name : 'Read environment' ,
101- } )
102- await db . insert ( permissions ) . values ( {
102+ return userId
103+ }
104+
105+ function grantWorkspace ( userId : string , workspaceId : string , permissionType : 'admin' | 'read' ) {
106+ return db . insert ( permissions ) . values ( {
103107 id : generateId ( ) ,
104108 userId,
105109 entityType : 'workspace' ,
106110 entityId : workspaceId ,
107- permissionType : 'admin' ,
111+ permissionType,
108112 } )
109- const [ binding ] = await db
110- . select ( { projectId : workspace . projectId } )
111- . from ( workspace )
112- . where ( eq ( workspace . id , workspaceId ) )
113- if ( ! binding ?. projectId ) throw new Error ( 'Project fixture missing' )
114- const ids = { userId, workspaceId, projectId : binding . projectId }
115- fixtures . push ( ids )
116- const context : AgentCliExecutionContext = {
113+ }
114+
115+ function cliContext ( userId : string , workspaceId : string ) : AgentCliExecutionContext {
116+ return {
117117 userId,
118118 workspaceId,
119119 requestMode : 'agent' ,
@@ -125,6 +125,26 @@ async function fixture() {
125125 } ) ,
126126 resolvedSecretTraceRegistry : new ResolvedSecretTraceRegistry ( [ ] , { userId, workspaceId } ) ,
127127 }
128+ }
129+
130+ async function fixture ( ) {
131+ const userId = await insertUser ( 'File reader' )
132+ const workspaceId = generateId ( )
133+ await insertWorkspaceFixture ( db , {
134+ id : workspaceId ,
135+ ownerId : userId ,
136+ billedAccountUserId : userId ,
137+ name : 'Read environment' ,
138+ } )
139+ await grantWorkspace ( userId , workspaceId , 'admin' )
140+ const [ binding ] = await db
141+ . select ( { projectId : workspace . projectId } )
142+ . from ( workspace )
143+ . where ( eq ( workspace . id , workspaceId ) )
144+ if ( ! binding ?. projectId ) throw new Error ( 'Project fixture missing' )
145+ const ids = { userId, workspaceId, projectId : binding . projectId }
146+ fixtures . push ( ids )
147+ const context = cliContext ( userId , workspaceId )
128148 const principal = createSessionPrincipal ( { userId } )
129149 return { ...ids , context, principal }
130150}
@@ -272,6 +292,137 @@ check('benchmark reference generation cannot create Project files', async () =>
272292 expect ( await stored ( ) ) . toHaveLength ( 1 )
273293} )
274294
295+ function simCli (
296+ argv : string [ ] ,
297+ context : ToolExecutionContext ,
298+ fileOwner ?: { entityType : 'project' ; entityId : string }
299+ ) {
300+ return executeSimCli (
301+ { request : { ...( fileOwner ? { fileOwner } : { } ) , invocation : { kind : 'cli' , argv } } } ,
302+ context
303+ )
304+ }
305+
306+ check ( 'sim_cli for a callerless run holds only the read API scopes' , async ( ) => {
307+ const f = await fixture ( )
308+ const name = `capped-${ generateId ( ) } `
309+ const ownerFallback = { ...f . context , workflowId : '' , secretActorUserId : null }
310+ // In-band calls carry no `userPermission`, only the run's recorded callerless authority.
311+ const contexts : ToolExecutionContext [ ] = [
312+ { ...ownerFallback , userPermission : 'read' } ,
313+ ownerFallback ,
314+ ]
315+ const project = { entityType : 'project' , entityId : f . projectId } as const
316+ const writes : Array < [ string [ ] , typeof project | undefined ] > = [
317+ [ [ 'workflows' , 'create' , '--name' , name ] , undefined ] ,
318+ [ [ 'projects' , 'files' , 'create' , f . projectId , '--name' , name , '--content' , 'x' ] , project ] ,
319+ [ [ 'secrets' , 'set' , 'CAPPED' , '--scope' , 'personal' , '--value' , 'v' ] , undefined ] ,
320+ ]
321+ const workflows = ( ) =>
322+ db
323+ . select ( { id : workflow . id } )
324+ . from ( workflow )
325+ . where ( and ( eq ( workflow . workspaceId , f . workspaceId ) , eq ( workflow . name , name ) ) )
326+ const projectFiles = ( ) =>
327+ db
328+ . select ( { id : workspaceFiles . id } )
329+ . from ( workspaceFiles )
330+ . where ( eq ( workspaceFiles . originalName , name ) )
331+
332+ for ( const context of contexts ) {
333+ for ( const [ argv , owner ] of writes ) {
334+ const refused = await simCli ( argv , context , owner )
335+ expect ( refused . success ) . toBe ( false )
336+ expect ( AgentCliRawResult . parse ( refused . output ) . stderr ) . toContain ( 'api:write' )
337+ }
338+ const read = await simCli ( [ 'workflows' , 'list' , '--output' , 'json' ] , context )
339+ expect ( read . success , read . error ) . toBe ( true )
340+ }
341+ expect ( await workflows ( ) ) . toEqual ( [ ] )
342+ expect ( await projectFiles ( ) ) . toEqual ( [ ] )
343+
344+ const attributed : ToolExecutionContext = {
345+ ...f . context ,
346+ workflowId : '' ,
347+ userPermission : 'admin' ,
348+ secretActorUserId : f . userId ,
349+ }
350+ for ( const [ argv , owner ] of writes ) {
351+ const written = await simCli ( argv , attributed , owner )
352+ expect ( written . success , written . error ) . toBe ( true )
353+ }
354+ expect ( await workflows ( ) ) . toHaveLength ( 1 )
355+ expect ( await projectFiles ( ) ) . toHaveLength ( 1 )
356+ } )
357+
358+ check ( 'sim_cli services for a callerless run refuse the writes they route' , async ( ) => {
359+ const f = await fixture ( )
360+ const service = ( name : string , input : Record < string , unknown > , context : ToolExecutionContext ) =>
361+ executeSimCli ( { request : { invocation : { kind : 'service' , name, input } } } , context )
362+ const inboxSecretScope = async ( ) =>
363+ (
364+ await db
365+ . select ( { scope : workspace . inboxSecretScope } )
366+ . from ( workspace )
367+ . where ( eq ( workspace . id , f . workspaceId ) )
368+ ) [ 0 ] ?. scope
369+ const before = await inboxSecretScope ( )
370+ const widen = before === 'all' ? 'selected' : 'all'
371+ const writes : Array < [ string , Record < string , unknown > ] > = [
372+ [
373+ 'settings' ,
374+ { scope : 'workspace' , action : 'update' , section : 'inbox' , changes : { secretScope : widen } } ,
375+ ] ,
376+ ]
377+
378+ const callerless : ToolExecutionContext = { ...f . context , workflowId : '' , secretActorUserId : null }
379+ for ( const [ name , input ] of writes ) {
380+ const refused = await service ( name , input , callerless )
381+ expect ( refused . success ) . toBe ( false )
382+ expect ( refused . error ) . toContain ( 'api:write' )
383+ }
384+ expect ( await inboxSecretScope ( ) ) . toBe ( before )
385+ const read = await service (
386+ 'settings' ,
387+ { scope : 'workspace' , action : 'get' , section : 'inbox' } ,
388+ callerless
389+ )
390+ expect ( read . success , read . error ) . toBe ( true )
391+
392+ const attributed : ToolExecutionContext = {
393+ ...f . context ,
394+ workflowId : '' ,
395+ userPermission : 'admin' ,
396+ secretActorUserId : f . userId ,
397+ }
398+ for ( const [ name , input ] of writes ) {
399+ const written = await service ( name , input , attributed )
400+ expect ( written . success , `${ name } : ${ written . error } ` ) . toBe ( true )
401+ }
402+ expect ( await inboxSecretScope ( ) ) . toBe ( widen )
403+ } )
404+
405+ check ( 'sim_cli for a read-only member keeps what that role allows' , async ( ) => {
406+ const f = await fixture ( )
407+ const memberId = await insertUser ( 'Read member' )
408+ memberIds . push ( memberId )
409+ await grantWorkspace ( memberId , f . workspaceId , 'read' )
410+ const member : ToolExecutionContext = {
411+ ...cliContext ( memberId , f . workspaceId ) ,
412+ workflowId : '' ,
413+ userPermission : 'read' ,
414+ }
415+
416+ const personalSecret = await simCli (
417+ [ 'secrets' , 'set' , 'MEMBER' , '--scope' , 'personal' , '--value' , 'v' ] ,
418+ member
419+ )
420+ expect ( personalSecret . success , personalSecret . error ) . toBe ( true )
421+ const workflowCreate = await simCli ( [ 'workflows' , 'create' , '--name' , 'x' ] , member )
422+ expect ( workflowCreate . success ) . toBe ( false )
423+ expect ( AgentCliRawResult . parse ( workflowCreate . output ) . stderr ) . not . toContain ( 'api:write' )
424+ } )
425+
275426check ( 'Project grep matches public bytes while excluding classified secret text' , async ( ) => {
276427 const f = await fixture ( )
277428 const secret = 'synthetic-project-grep-canary'
@@ -771,6 +922,7 @@ afterAll(async () => {
771922 await deleteWorkspaceFixture ( db , eq ( workspace . id , f . workspaceId ) )
772923 await db . delete ( user ) . where ( eq ( user . id , f . userId ) )
773924 }
925+ if ( memberIds . length ) await db . delete ( user ) . where ( inArray ( user . id , memberIds ) )
774926 await rm ( storageRoot , { recursive : true , force : true } )
775927 const report = process . env . PROJECT_FILE_READ_REPORT_PATH ?? 'test-results/project-file-read.json'
776928 await mkdir ( dirname ( report ) , { recursive : true } )
0 commit comments