Skip to content

Commit d4fed76

Browse files
committed
mothership agent-cli: viewer curation primitive for blocks get (Phase B3)
The v2 catalog already gates visibility, the integration allowlist, hidden-from- toolbar and hosted-key restrictions; the one Go-era curation it lacks is a permission group's deniedTools. When the mothership marks a request `curate: "block"`, `curation.ts` applies resolveDeniedBlockOperations to the block detail: a partially denied block loses the denied operations and their tools, a fully denied one is refused. Contract mirror synced; no v2 or public CLI change. Claude-Session: https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8
1 parent 858d7ba commit d4fed76

5 files changed

Lines changed: 147 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { curateBlockDetail } from '@/lib/mothership/agent-cli/curation'
3+
4+
const { permissionConfig, denied } = vi.hoisted(() => ({
5+
permissionConfig: { current: null as { deniedTools?: string[] } | null },
6+
denied: {
7+
current: {
8+
needsProjection: new Map<string, ReadonlySet<string>>(),
9+
fullyDenied: new Set<string>(),
10+
},
11+
},
12+
}))
13+
14+
vi.mock('@/ee/access-control/utils/permission-check', () => ({
15+
getUserPermissionConfig: vi.fn(async () => permissionConfig.current),
16+
}))
17+
18+
vi.mock('@/lib/mothership/integration-tool-projection', () => ({
19+
resolveDeniedBlockOperations: vi.fn(() => denied.current),
20+
}))
21+
22+
const viewer = { workspaceId: 'ws', userId: 'user' }
23+
24+
function blockDetail() {
25+
return {
26+
type: 'slack',
27+
operations: {
28+
send: { toolId: 'slack_send' },
29+
canvas: { toolId: 'slack_canvas' },
30+
},
31+
tools: [{ id: 'slack_send' }, { id: 'slack_canvas' }],
32+
}
33+
}
34+
35+
function ok(stdout: string) {
36+
return { exitCode: 0, stdout, stderr: '' }
37+
}
38+
39+
describe('curateBlockDetail', () => {
40+
beforeEach(() => {
41+
permissionConfig.current = null
42+
denied.current = { needsProjection: new Map(), fullyDenied: new Set() }
43+
})
44+
45+
it('passes through when the viewer has no denied tools', async () => {
46+
const input = ok(JSON.stringify(blockDetail()))
47+
expect(await curateBlockDetail(input, viewer)).toBe(input)
48+
})
49+
50+
it('passes through non-block output untouched', async () => {
51+
permissionConfig.current = { deniedTools: ['slack_canvas'] }
52+
const input = ok('not json')
53+
expect(await curateBlockDetail(input, viewer)).toBe(input)
54+
})
55+
56+
it('drops denied operations and their tools from a partially denied block', async () => {
57+
permissionConfig.current = { deniedTools: ['slack_canvas'] }
58+
denied.current = {
59+
needsProjection: new Map([['slack', new Set(['canvas'])]]),
60+
fullyDenied: new Set(),
61+
}
62+
const result = await curateBlockDetail(ok(JSON.stringify(blockDetail())), viewer)
63+
expect(result.exitCode).toBe(0)
64+
const curated = JSON.parse(result.stdout)
65+
expect(Object.keys(curated.operations)).toEqual(['send'])
66+
expect(curated.tools).toEqual([{ id: 'slack_send' }])
67+
})
68+
69+
it('refuses a fully denied block', async () => {
70+
permissionConfig.current = { deniedTools: ['slack_send', 'slack_canvas'] }
71+
denied.current = { needsProjection: new Map(), fullyDenied: new Set(['slack']) }
72+
const result = await curateBlockDetail(ok(JSON.stringify(blockDetail())), viewer)
73+
expect(result.exitCode).toBe(1)
74+
expect(result.stderr).toContain('not available to you')
75+
})
76+
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Viewer curation for `blocks get` (18-agent-surface.md B3). The v2 catalog already
3+
* hides blocks by visibility, allowlist and hosted-key restrictions, but it does not
4+
* apply a permission group's `deniedTools`; the mothership asks for `curate: "block"`
5+
* so a partially-denied block is trimmed to the operations this viewer may configure.
6+
*/
7+
8+
import { agentCliFail } from '@/lib/mothership/agent-cli/types'
9+
import type { AgentCliRawResult } from '@/lib/mothership/generated/agent-cli'
10+
import { resolveDeniedBlockOperations } from '@/lib/mothership/integration-tool-projection'
11+
import { createToolAccessGate } from '@/lib/permission-groups/operation-access'
12+
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
13+
14+
export interface CurationViewer {
15+
workspaceId: string
16+
userId: string
17+
}
18+
19+
interface BlockDetailShape {
20+
type: string
21+
operations?: Record<string, unknown>
22+
tools?: Array<{ id?: unknown }>
23+
}
24+
25+
function parseBlockDetail(stdout: string): BlockDetailShape | null {
26+
let parsed: unknown
27+
try {
28+
parsed = JSON.parse(stdout)
29+
} catch {
30+
return null
31+
}
32+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
33+
const candidate = parsed as { type?: unknown }
34+
return typeof candidate.type === 'string' ? (parsed as BlockDetailShape) : null
35+
}
36+
37+
export async function curateBlockDetail(
38+
result: AgentCliRawResult,
39+
viewer: CurationViewer
40+
): Promise<AgentCliRawResult> {
41+
const detail = parseBlockDetail(result.stdout)
42+
if (!detail) return result
43+
const config = await getUserPermissionConfig(viewer.userId, viewer.workspaceId)
44+
const deniedTools = config?.deniedTools
45+
if (!deniedTools?.length) return result
46+
const isToolAllowed = createToolAccessGate(deniedTools)
47+
const denied = resolveDeniedBlockOperations(deniedTools, isToolAllowed)
48+
if (denied.fullyDenied.has(detail.type)) {
49+
return agentCliFail(`Block "${detail.type}" is not available to you in this workspace.`)
50+
}
51+
const deniedOperations = denied.needsProjection.get(detail.type)
52+
if (!deniedOperations) return result
53+
const operations = Object.fromEntries(
54+
Object.entries(detail.operations ?? {}).filter(([id]) => !deniedOperations.has(id))
55+
)
56+
const tools = (detail.tools ?? []).filter(
57+
(tool) => typeof tool.id !== 'string' || isToolAllowed(tool.id)
58+
)
59+
return { ...result, stdout: JSON.stringify({ ...detail, operations, tools }, null, 2) }
60+
}

apps/sim/lib/mothership/agent-cli/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createEmbeddedClient, type EmbeddedCliIdentity } from 'sim/embed'
22
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
3+
import { curateBlockDetail } from '@/lib/mothership/agent-cli/curation'
34
import { runEngine } from '@/lib/mothership/agent-cli/engines'
45
import { applyPipeline } from '@/lib/mothership/agent-cli/pipeline'
56
import { runCli } from '@/lib/mothership/agent-cli/run-cli'
@@ -51,6 +52,9 @@ export async function executeAgentCliRequest(
5152
)
5253
} else {
5354
result = await runCli(request.invocation.argv, identity, sessionKey)
55+
if (result.exitCode === 0 && request.curate === 'block') {
56+
result = await curateBlockDetail(result, context)
57+
}
5458
}
5559
if (result.exitCode === 0 && request.pipeline.length > 0) {
5660
result = await applyPipeline(result, request.pipeline)

apps/sim/lib/mothership/agent-cli/request-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ export const agentCliRequestSchema = z.object({
3636
])
3737
),
3838
sink: z.object({ kind: z.literal('sandbox-file'), path: z.string().min(1).max(300) }).optional(),
39+
curate: z.literal('block').optional(),
3940
}) satisfies z.ZodType<AgentCliRequest>

apps/sim/lib/mothership/generated/agent-cli.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ export interface AgentCliRequest {
6969
invocation: AgentCliInvocation;
7070
pipeline: AgentCliPipeStage[];
7171
sink?: AgentCliSink;
72+
/**
73+
* Viewer curation sim applies to the raw result before the pipeline: "block" trims a
74+
* block detail to the operations, inputs and models this viewer may use. Decided by the
75+
* worker's parse, applied by sim's primitive, so both sides see one policy.
76+
*/
77+
curate?: "block";
7278
}
7379

7480
/** What sim returns; the worker shapes the model-facing result from it. */

0 commit comments

Comments
 (0)