From 21f8bede2e1ff4a19745a638321e9c28b00fb9bc Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 29 Apr 2026 00:46:06 +0100 Subject: [PATCH] feat(mcpd+db): visibility scope + ownership for Llms and Agents (v7 Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the schema + service-layer machinery for per-user RBAC scoping of virtual Llms and Agents. Without this, anyone with `view:llms` sees every other user's published model — fine for a single-user homelab, wrong for org use where workstation-published models or paid keys aren't meant to be broadcast. Schema: - Llm: new `ownerId String?` + `visibility String @default("public")`. NULL ownerId on legacy rows is treated as public for back-compat. - Agent: `visibility String @default("public")` (Agent already has `ownerId`, required). - Composite index `(visibility, ownerId)` on both tables for the list-filter hot path. - Migration backfills both columns to 'public' so pre-v7 setups behave identically post-deploy. Service layer: - New `Viewer` / `AgentViewer` shape: `{ userId, wildcard, allowedNames }`. The route layer computes this from `request.userId` + `RbacService.getAllowedScope` and passes it down. NULL viewer = skip the filter (internal callers — cron sweeps, audit, tests). - `isLlmVisibleTo` / `isAgentVisibleTo` pure predicates encode the decision tree: visibility=public → visible (RBAC layer above already passed) viewer=null OR wildcard → visible ownerId === viewer.userId → visible row.name in viewer.allowedNames → visible else → hidden - LlmService.list/getById/getByName + AgentService equivalents accept an optional Viewer arg and apply the predicate. Get-style methods 404 (not 403) on hidden rows so name enumeration via differential status is impossible. Repositories: CreateInput/UpdateInput types gained `ownerId`/ `visibility` (Llm) and `visibility` (Agent). Update is in place; ownerId is set-once at create time. Tests: - 13 unit tests on the predicate covering every branch (null viewer, public, wildcard, owner, name-scoped grant, foreign private, legacy null-ownerId). - mcpd 908/908 (was 893; +15 across the merge windows + this PR). Stage 2 (next): route plumbing — every list/get endpoint needs to build the Viewer from the request and pass it through. mcplocal virtuals default to visibility=private on register. CLI adds a VISIBILITY column and a --visibility flag. yaml round-trip preserves the field. --- .../migration.sql | 25 +++++ src/db/prisma/schema.prisma | 37 ++++++ src/mcpd/src/repositories/agent.repository.ts | 7 ++ src/mcpd/src/repositories/llm.repository.ts | 12 ++ src/mcpd/src/services/agent.service.ts | 42 ++++++- src/mcpd/src/services/llm.service.ts | 69 +++++++++++- src/mcpd/tests/visibility-filter.test.ts | 105 ++++++++++++++++++ 7 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql create mode 100644 src/mcpd/tests/visibility-filter.test.ts diff --git a/src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql b/src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql new file mode 100644 index 0000000..f577bdb --- /dev/null +++ b/src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql @@ -0,0 +1,25 @@ +-- v7: per-user RBAC scoping for virtual Llms and Agents. +-- +-- `Llm.ownerId` is new — we don't have a record of who created legacy +-- rows, so existing data is left NULL (treated as "no owner, public"). +-- The list/get filter in the service layer handles NULL ownerId +-- correctly: a NULL-owner public row stays visible to everyone. +-- +-- `Llm.visibility` and `Agent.visibility` default to 'public' so the +-- backfill is automatic — pre-v7 setups continue to behave identically. +-- New rows created post-deploy carry the value the service writes +-- (mcplocal virtuals → 'private'; CLI `mcpctl create llm` → 'public' +-- by default unless `--visibility private` is passed). + +ALTER TABLE "Llm" + ADD COLUMN "ownerId" TEXT, + ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'public'; + +ALTER TABLE "Agent" + ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'public'; + +-- Composite index supports the list-filter hot path: +-- `WHERE visibility='public' OR ownerId=$1` +-- on tables that may grow as more publishers / users come online. +CREATE INDEX "Llm_visibility_ownerId_idx" ON "Llm"("visibility", "ownerId"); +CREATE INDEX "Agent_visibility_ownerId_idx" ON "Agent"("visibility", "ownerId"); diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 4289ae0..1c70d95 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -225,6 +225,30 @@ model Llm { lastHeartbeatAt DateTime? // bumped on every publisher heartbeat status LlmStatus @default(active) inactiveSince DateTime? // when status flipped from active; used for 4-h GC + // ── Per-user RBAC scoping (v7) ── + // `ownerId` records who created/published the row. NULL on legacy rows + // (those created before the v7 migration) — those continue to behave + // as `visibility=public` for back-compat. New rows always carry an + // ownerId set by the service layer (`User.id` of the authenticated + // caller, or the publishing mcplocal user for virtuals). + // + // `visibility` controls who can see / use the row: + // - 'public' : anyone with the resource grant (`view:llms`, + // `run:llms:`, etc.) sees it. Legacy default + // mirrors today's behavior — explicit + // `mcpctl create llm` calls keep this default. + // - 'private' : only the owner sees it by default; other users need + // an explicit name-scoped RBAC binding + // (`view:llms:`, `run:llms:`). The list + // endpoint hides foreign-private rows from + // unauthorized callers; get/describe returns 404 to + // prevent name enumeration. + // + // mcplocal-published virtual Llms default to 'private' on register — + // a workstation-published model isn't typically meant for the whole + // org until the publisher explicitly shares it. See docs/virtual-llms.md. + ownerId String? + visibility String @default("public") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -238,6 +262,10 @@ model Llm { @@index([kind, status]) @@index([providerSessionId]) @@index([poolName]) + // List filter on the hot path: "rows visible to caller X" decomposes + // into `visibility='public' OR ownerId=X` + an RBAC join. Composite + // index keeps the predicate fast even on a large table. + @@index([visibility, ownerId]) } // ── Groups ── @@ -495,6 +523,14 @@ model Agent { status LlmStatus @default(active) inactiveSince DateTime? ownerId String + // v7: per-user RBAC scoping. Mirrors `Llm.visibility` semantics — + // 'public' (default, today's behavior) lets anyone with the resource + // grant see the agent; 'private' restricts to owner + explicit + // name-scoped RBAC bindings. mcplocal-published virtual agents + // default to 'private' on register so a workstation-published persona + // isn't broadcast to the whole org until shared explicitly. Existing + // rows backfill to 'public' so pre-v7 setups keep working unchanged. + visibility String @default("public") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -514,6 +550,7 @@ model Agent { @@index([defaultPersonalityId]) @@index([kind, status]) @@index([providerSessionId]) + @@index([visibility, ownerId]) } // ── Personalities (named overlay bundles of prompts on top of an Agent) ── diff --git a/src/mcpd/src/repositories/agent.repository.ts b/src/mcpd/src/repositories/agent.repository.ts index 9f83541..271ef25 100644 --- a/src/mcpd/src/repositories/agent.repository.ts +++ b/src/mcpd/src/repositories/agent.repository.ts @@ -11,6 +11,8 @@ export interface CreateAgentRepoInput { defaultParams?: Record; extras?: Record; ownerId: string; + // v7: optional visibility scope (default 'public' if omitted). + visibility?: 'public' | 'private'; // Virtual-agent lifecycle (omit for kind=public). kind?: LlmKind; providerSessionId?: string | null; @@ -28,6 +30,9 @@ export interface UpdateAgentRepoInput { proxyModelName?: string | null; defaultParams?: Record; extras?: Record; + // v7: visibility is mutable (operator can flip a private virtual to + // public for org-wide sharing without re-creating). + visibility?: 'public' | 'private'; // Virtual-agent lifecycle. AgentService is the only public writer; the // VirtualAgentService methods (Stage 2) bypass the public CRUD path. kind?: LlmKind; @@ -87,6 +92,7 @@ export class AgentRepository implements IAgentRepository { defaultParams: (data.defaultParams ?? {}) as Prisma.InputJsonValue, extras: (data.extras ?? {}) as Prisma.InputJsonValue, ownerId: data.ownerId, + ...(data.visibility !== undefined ? { visibility: data.visibility } : {}), ...(data.kind !== undefined ? { kind: data.kind } : {}), ...(data.providerSessionId !== undefined ? { providerSessionId: data.providerSessionId } : {}), ...(data.status !== undefined ? { status: data.status } : {}), @@ -122,6 +128,7 @@ export class AgentRepository implements IAgentRepository { if (data.extras !== undefined) { updateData.extras = data.extras as Prisma.InputJsonValue; } + if (data.visibility !== undefined) updateData.visibility = data.visibility; if (data.kind !== undefined) updateData.kind = data.kind; if (data.providerSessionId !== undefined) updateData.providerSessionId = data.providerSessionId; if (data.status !== undefined) updateData.status = data.status; diff --git a/src/mcpd/src/repositories/llm.repository.ts b/src/mcpd/src/repositories/llm.repository.ts index 8942e19..b10bd64 100644 --- a/src/mcpd/src/repositories/llm.repository.ts +++ b/src/mcpd/src/repositories/llm.repository.ts @@ -12,6 +12,11 @@ export interface CreateLlmInput { extraConfig?: Record; // v4: optional pool key. NULL = "pool of 1" (effective key falls back to `name`). poolName?: string | null; + // v7: per-user RBAC scoping. ownerId is set by the service layer to + // the authenticated caller's User.id; visibility defaults to 'public' + // and gets flipped to 'private' for mcplocal-published virtuals. + ownerId?: string | null; + visibility?: 'public' | 'private'; // Virtual-provider lifecycle (omit for kind=public). kind?: LlmKind; providerSessionId?: string | null; @@ -30,6 +35,10 @@ export interface UpdateLlmInput { apiKeySecretKey?: string | null; extraConfig?: Record; poolName?: string | null; + // v7: ownerId immutable at update time (use a separate transfer flow if + // ever needed). Visibility is mutable so an operator can flip a + // virtual Llm to public for org-wide sharing without re-creating it. + visibility?: 'public' | 'private'; // Virtual-provider lifecycle. VirtualLlmService is the only writer for // these in v1; the public CRUD path leaves them undefined. kind?: LlmKind; @@ -108,6 +117,8 @@ export class LlmRepository implements ILlmRepository { apiKeySecretKey: data.apiKeySecretKey ?? null, extraConfig: (data.extraConfig ?? {}) as Prisma.InputJsonValue, ...(data.poolName !== undefined ? { poolName: data.poolName } : {}), + ...(data.ownerId !== undefined ? { ownerId: data.ownerId } : {}), + ...(data.visibility !== undefined ? { visibility: data.visibility } : {}), ...(data.kind !== undefined ? { kind: data.kind } : {}), ...(data.providerSessionId !== undefined ? { providerSessionId: data.providerSessionId } : {}), ...(data.status !== undefined ? { status: data.status } : {}), @@ -132,6 +143,7 @@ export class LlmRepository implements ILlmRepository { if (data.apiKeySecretKey !== undefined) updateData.apiKeySecretKey = data.apiKeySecretKey; if (data.extraConfig !== undefined) updateData.extraConfig = data.extraConfig as Prisma.InputJsonValue; if (data.poolName !== undefined) updateData.poolName = data.poolName; + if (data.visibility !== undefined) updateData.visibility = data.visibility; if (data.kind !== undefined) updateData.kind = data.kind; if (data.providerSessionId !== undefined) updateData.providerSessionId = data.providerSessionId; if (data.status !== undefined) updateData.status = data.status; diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index 601d0dc..4875766 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -21,6 +21,29 @@ import { } from '../validation/agent.schema.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js'; +/** + * v7: visibility scope for the current request, mirrors LlmService.Viewer. + * Same semantics — null = no filter; wildcard = full grant; else + * filter to public-or-owned-or-name-scoped. + */ +export interface AgentViewer { + userId: string; + wildcard: boolean; + allowedNames: Set; +} + +export function isAgentVisibleTo( + row: { name: string; ownerId: string; visibility: string }, + viewer: AgentViewer | null, +): boolean { + if (viewer === null) return true; + if (viewer.wildcard) return true; + if (row.visibility !== 'private') return true; + if (row.ownerId === viewer.userId) return true; + if (viewer.allowedNames.has(row.name)) return true; + return false; +} + /** Shape returned by the API layer — embeds llm + project metadata. */ export interface AgentView { id: string; @@ -39,6 +62,8 @@ export interface AgentView { lastHeartbeatAt: Date | null; inactiveSince: Date | null; ownerId: string; + /** v7: per-user RBAC scoping. mcplocal-published virtuals default to 'private'. */ + visibility: 'public' | 'private'; version: number; createdAt: Date; updatedAt: Date; @@ -63,26 +88,30 @@ export class AgentService { private readonly personalities?: IPersonalityRepository, ) {} - async list(): Promise { + async list(viewer: AgentViewer | null = null): Promise { const rows = await this.repo.findAll(); - return Promise.all(rows.map((r) => this.toView(r))); + const visible = rows.filter((r) => isAgentVisibleTo(r, viewer)); + return Promise.all(visible.map((r) => this.toView(r))); } - async listByProject(projectName: string): Promise { + async listByProject(projectName: string, viewer: AgentViewer | null = null): Promise { const project = await this.projects.resolveAndGet(projectName); const rows = await this.repo.findByProjectId(project.id); - return Promise.all(rows.map((r) => this.toView(r))); + const visible = rows.filter((r) => isAgentVisibleTo(r, viewer)); + return Promise.all(visible.map((r) => this.toView(r))); } - async getById(id: string): Promise { + async getById(id: string, viewer: AgentViewer | null = null): Promise { const row = await this.repo.findById(id); if (row === null) throw new NotFoundError(`Agent not found: ${id}`); + if (!isAgentVisibleTo(row, viewer)) throw new NotFoundError(`Agent not found: ${id}`); return this.toView(row); } - async getByName(name: string): Promise { + async getByName(name: string, viewer: AgentViewer | null = null): Promise { const row = await this.repo.findByName(name); if (row === null) throw new NotFoundError(`Agent not found: ${name}`); + if (!isAgentVisibleTo(row, viewer)) throw new NotFoundError(`Agent not found: ${name}`); return this.toView(row); } @@ -200,6 +229,7 @@ export class AgentService { lastHeartbeatAt: row.lastHeartbeatAt, inactiveSince: row.inactiveSince, ownerId: row.ownerId, + visibility: (row.visibility === 'private' ? 'private' : 'public') as 'public' | 'private', version: row.version, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/src/mcpd/src/services/llm.service.ts b/src/mcpd/src/services/llm.service.ts index af37176..1ee2149 100644 --- a/src/mcpd/src/services/llm.service.ts +++ b/src/mcpd/src/services/llm.service.ts @@ -57,6 +57,17 @@ export interface LlmView { * expands at request time. */ poolName: string | null; + /** + * v7: per-user RBAC scoping. NULL `ownerId` on legacy rows means + * "no recorded owner"; treated as public for visibility checks. + */ + ownerId: string | null; + /** + * v7: visibility scope. 'public' = anyone with the resource grant; + * 'private' = owner + explicit name-scoped RBAC bindings only. + * mcplocal virtuals default to 'private' on register. + */ + visibility: 'public' | 'private'; // Virtual-provider lifecycle (kind defaults to 'public' for legacy rows). kind: 'public' | 'virtual'; status: 'active' | 'inactive' | 'hibernating'; @@ -77,6 +88,49 @@ export function effectivePoolName(row: { name: string; poolName: string | null } return row.poolName !== null && row.poolName !== '' ? row.poolName : row.name; } +/** + * v7: visibility scope for the current request. The route layer + * computes this from `request.userId` + `RbacService.getAllowedScope` + * and passes it down to LlmService for filtering. When `wildcard` is + * true (admin / resource-wide `view:llms`), no filter applies — every + * row is visible regardless of owner / private flag. When false, the + * filter is `visibility='public' OR ownerId=userId OR name in allowedNames`. + * + * `null` Viewer means "skip the v7 filter entirely" — used by internal + * callers (cron sweeps, audit collectors) and tests that don't have a + * request context. + */ +export interface Viewer { + userId: string; + /** True when the caller has resource-wide `view:llms` (or admin). */ + wildcard: boolean; + /** Name-scoped grants the caller holds (e.g. `view:llms:vllm-alice`). */ + allowedNames: Set; +} + +/** + * v7: shared predicate. A row is visible to the viewer when: + * - visibility is public AND row passes RBAC layer above (caller already has resource grant), OR + * - viewer is null (internal call, no filter), OR + * - viewer has wildcard grant, OR + * - viewer is the owner, OR + * - the row's name is in viewer.allowedNames (name-scoped grant). + * + * Pure function so service tests can exercise it directly without a + * full mock RbacService. + */ +export function isLlmVisibleTo( + row: { name: string; ownerId: string | null; visibility: string }, + viewer: Viewer | null, +): boolean { + if (viewer === null) return true; + if (viewer.wildcard) return true; + if (row.visibility !== 'private') return true; + if (row.ownerId !== null && row.ownerId === viewer.userId) return true; + if (viewer.allowedNames.has(row.name)) return true; + return false; +} + export class LlmService { constructor( private readonly repo: ILlmRepository, @@ -84,20 +138,25 @@ export class LlmService { private readonly verifyDeps: LlmServiceDeps = {}, ) {} - async list(): Promise { + async list(viewer: Viewer | null = null): Promise { const rows = await this.repo.findAll(); - return Promise.all(rows.map((r) => this.toView(r))); + const visible = rows.filter((r) => isLlmVisibleTo(r, viewer)); + return Promise.all(visible.map((r) => this.toView(r))); } - async getById(id: string): Promise { + async getById(id: string, viewer: Viewer | null = null): Promise { const row = await this.repo.findById(id); if (row === null) throw new NotFoundError(`Llm not found: ${id}`); + // 404 (not 403) on a foreign-private row prevents id-enumeration — + // identical shape to the chat-thread + inference-task routes. + if (!isLlmVisibleTo(row, viewer)) throw new NotFoundError(`Llm not found: ${id}`); return this.toView(row); } - async getByName(name: string): Promise { + async getByName(name: string, viewer: Viewer | null = null): Promise { const row = await this.repo.findByName(name); if (row === null) throw new NotFoundError(`Llm not found: ${name}`); + if (!isLlmVisibleTo(row, viewer)) throw new NotFoundError(`Llm not found: ${name}`); return this.toView(row); } @@ -326,6 +385,8 @@ export class LlmService { apiKeyRef, extraConfig: row.extraConfig as Record, poolName: row.poolName, + ownerId: row.ownerId, + visibility: (row.visibility === 'private' ? 'private' : 'public') as 'public' | 'private', kind: row.kind, status: row.status, lastHeartbeatAt: row.lastHeartbeatAt, diff --git a/src/mcpd/tests/visibility-filter.test.ts b/src/mcpd/tests/visibility-filter.test.ts new file mode 100644 index 0000000..8758e9b --- /dev/null +++ b/src/mcpd/tests/visibility-filter.test.ts @@ -0,0 +1,105 @@ +/** + * v7 Stage 1 — pure-function tests for the visibility predicate. + * Lives separately from the service-level tests because the predicate + * is the single source of truth for "can user X see this row" and is + * called from both LlmService.list and AgentService.list. We exercise + * every branch of the decision tree to lock the semantics in. + */ +import { describe, it, expect } from 'vitest'; +import { isLlmVisibleTo, type Viewer } from '../src/services/llm.service.js'; +import { isAgentVisibleTo, type AgentViewer } from '../src/services/agent.service.js'; + +const llmRow = (overrides: { name?: string; ownerId?: string | null; visibility?: string } = {}): { name: string; ownerId: string | null; visibility: string } => ({ + name: 'vllm-alice', + ownerId: 'alice', + visibility: 'private', + ...overrides, +}); + +const agentRow = (overrides: { name?: string; ownerId?: string; visibility?: string } = {}): { name: string; ownerId: string; visibility: string } => ({ + name: 'reviewer', + ownerId: 'alice', + visibility: 'private', + ...overrides, +}); + +describe('isLlmVisibleTo (v7)', () => { + it('null viewer skips the filter — internal callers see everything', () => { + // Cron sweeps, audit collectors, and tests without a request + // context get a null viewer. The visibility filter is then a + // no-op, which matches the pre-v7 behavior of those code paths. + expect(isLlmVisibleTo(llmRow(), null)).toBe(true); + }); + + it('public rows are visible to anyone with the resource grant', () => { + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'public' }), v)).toBe(true); + }); + + it('wildcard viewer (admin) sees private rows owned by others', () => { + const v: Viewer = { userId: 'admin', wildcard: true, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('owner sees their own private row', () => { + const v: Viewer = { userId: 'alice', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('non-owner without name-scoped grant cannot see a private row', () => { + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(false); + }); + + it('non-owner WITH name-scoped grant can see a private row', () => { + // alice published vllm-alice as private; alice ran + // `mcpctl create rbac binding view:llms:vllm-alice --user bob`, + // so bob now sees the row in his list output. + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set(['vllm-alice']) }; + expect(isLlmVisibleTo(llmRow({ name: 'vllm-alice', visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('treats null ownerId as no-owner (legacy rows pre-v7 backfill stay visible if public)', () => { + // The migration sets visibility='public' for legacy rows, so they + // pass the public-visibility check before the ownerId branch is + // ever reached. A row with NULL ownerId AND visibility='private' + // is unreachable via normal flows, but we still want the predicate + // to behave: no owner + bob viewing = not visible. + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ ownerId: null, visibility: 'private' }), v)).toBe(false); + }); +}); + +describe('isAgentVisibleTo (v7)', () => { + // Same shape as Llm; agents always have a non-null ownerId because + // `Agent.ownerId` is required, so we don't need the legacy-null + // branch test. + it('null viewer = visible (internal calls bypass filter)', () => { + expect(isAgentVisibleTo(agentRow(), null)).toBe(true); + }); + + it('public agents visible to anyone with resource grant', () => { + const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'public' }), v)).toBe(true); + }); + + it('owner sees own private agent', () => { + const v: AgentViewer = { userId: 'alice', wildcard: false, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('non-owner without grant blocked from private agent', () => { + const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(false); + }); + + it('non-owner WITH name-scoped grant can see private agent', () => { + const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set(['reviewer']) }; + expect(isAgentVisibleTo(agentRow({ name: 'reviewer', visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('wildcard viewer sees private agent owned by another user', () => { + const v: AgentViewer = { userId: 'admin', wildcard: true, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); +});