History

Enhance elicitation handling and introduce tool role management

- Added support for anonymous MCP API key linking and elicitation requests in the bot. - Introduced new interfaces for elicitation requests and responses in the agent. - Updated the server to manage pending elicitation responses and integrate tool role policies. - Refactored access policy handling to utilize tool roles, improving role-based policy evaluation. - Enhanced runtime configuration to allow local VM execution based on environment settings.

Rhys Sullivan committed 8 months ago

Commit cffff331 parent a63e82345 files · +3020 −775Browse files

Unified · Split

45 files
  1. .gitignore +1 −0
  2. assistant/packages/bot/src/index.ts +848 −20
  3. assistant/packages/core/src/agent.ts +92 −6
  4. assistant/packages/core/src/index.ts +7 −1
  5. assistant/packages/server/src/link-store.ts +1 −0
  6. assistant/packages/server/src/routes.ts +288 −9
  7. executor/apps/web/src/components/tasks/task-composer.tsx +7 −5
  8. executor/apps/web/src/components/tools/policies.tsx +1039 −77
  9. executor/apps/web/src/hooks/use/workspace-tools.ts +0 −5
  10. executor/apps/web/src/lib/runtime-config.ts +16 −0
  11. executor/apps/web/src/lib/runtime-targets.ts +23 −7
  12. executor/apps/web/src/lib/session-context.tsx +5 −0
  13. executor/apps/web/src/lib/types.ts +12 −17
  14. executor/packages/core/src/function-builders.ts +15 −0
  15. executor/packages/core/src/mcp-server.ts +1 −3
  16. executor/packages/core/src/runtimes/cloudflare/worker/loader-runtime.ts +10 −1
  17. executor/packages/core/src/types.ts +1 −1
  18. executor/packages/database/convex/_generated/api.d.ts +13 −106
  19. executor/packages/database/convex/access-controls.test.ts +226 −30
  20. executor/packages/database/convex/app.ts +17 −0
  21. executor/packages/database/convex/database.ts +1 −3
  22. executor/packages/database/convex/database/anonymous_session.ts +63 −1
  23. executor/packages/database/convex/database/policies.ts +20 −293
  24. executor/packages/database/convex/database/tool_sources.ts +12 −0
  25. executor/packages/database/convex/executor.ts +0 −1
  26. executor/packages/database/convex/executorNode.ts +7 −9
  27. executor/packages/database/convex/http/mcp_auth.ts +2 −4
  28. executor/packages/database/convex/http/mcp_handler.ts +3 −3
  29. executor/packages/database/convex/http/mcp_service.ts +4 −1
  30. executor/packages/database/convex/invites.ts +0 −9
  31. executor/packages/database/convex/workspace.ts +3 −70
  32. executor/packages/database/src/auth/account_identity.ts +18 −0
  33. executor/packages/database/src/auth/event_handlers.ts +17 −0
  34. executor/packages/database/src/executor/handlers.ts +6 −2
  35. executor/packages/database/src/runtime/account_auth.ts +7 −1
  36. executor/packages/database/src/runtime/policy.test.ts +33 −5
  37. executor/packages/database/src/runtime/policy.ts +7 −11
  38. executor/packages/database/src/runtime/tool_call_resolution.ts +2 −2
  39. executor/packages/database/src/runtime/tool_descriptors.ts +2 −2
  40. executor/packages/database/src/runtime/tool_invocation.ts +29 −6
  41. executor/packages/database/src/runtime/workspace_tools.ts +8 −8
  42. executor/packages/runner-sandbox-host/src/bridge.ts +34 −48
  43. executor/packages/runner-sandbox-host/src/index.ts +4 −2
  44. executor/packages/runner-sandbox-host/src/isolate/harness.isolate.js +114 −4
  45. executor/packages/runner-sandbox-host/src/types.ts +2 −2
.gitignore+1−0
@@ -56,3 +56,4 @@ executor/.env.executor-push · 55 unmodified lines
5656 *.har
5757 .vercel
5858 .env*.local
59+.chat-links.json
assistant/packages/bot/src/index.ts+848−20
@@ -44,6 +44,69 @@ const LINK_OPEN_MODAL_BUTTON_ID = "assistant_link_workos_open_modal"; · 43 unmodified lines
4444 const LINK_FIELD_WORKSPACE = "workspace_id";
4545 const LINK_FIELD_TOKEN = "access_token";
4646 const LINK_FIELD_ACCOUNT = "account_id";
47+const ANON_LINK_MODAL_ID = "assistant_link_anonymous_modal";
48+const ANON_LINK_FIELD_WORKSPACE = "workspace_id";
49+const ANON_LINK_FIELD_API_KEY = "api_key";
50+const ELICITATION_BUTTON_PREFIX = "assistant_elicitation";
51+const ELICITATION_FORM_MODAL_PREFIX = "assistant_elicitation_form";
52+const ELICITATION_POLL_INTERVAL_MS = 1000;
53+const ELICITATION_FORM_SESSION_TIMEOUT_MS = 10 * 60 * 1000;
54+const MAX_DISCORD_MODAL_FIELDS = 5;
55+
56+type ElicitationAction = "accept" | "decline" | "cancel";
57+type ElicitationButtonAction = ElicitationAction | "form";
58+
59+type FormFieldKind = "string" | "number" | "integer" | "boolean" | "enum_single" | "enum_multi";
60+
61+interface EnumOption {
62+ readonly value: string | number | boolean;
63+ readonly title?: string;
64+}
65+
66+interface FormFieldSpec {
67+ readonly key: string;
68+ readonly label: string;
69+ readonly description?: string;
70+ readonly required: boolean;
71+ readonly kind: FormFieldKind;
72+ readonly minLength?: number;
73+ readonly maxLength?: number;
74+ readonly pattern?: string;
75+ readonly minimum?: number;
76+ readonly maximum?: number;
77+ readonly minItems?: number;
78+ readonly maxItems?: number;
79+ readonly options?: readonly EnumOption[];
80+ readonly defaultValue?: string | number | boolean | readonly (string | number | boolean)[];
81+}
82+
83+interface FormSchemaSpec {
84+ readonly fields: readonly FormFieldSpec[];
85+ readonly error?: string;
86+}
87+
88+interface FormModalSession {
89+ readonly requestId: string;
90+ readonly elicitationRequestId: string;
91+ readonly userId: string;
92+ readonly fields: readonly FormFieldSpec[];
93+ readonly timeout: ReturnType<typeof setTimeout>;
94+}
95+
96+interface PendingElicitation {
97+ readonly id: string;
98+ readonly requestId: string;
99+ readonly mode: "form" | "url";
100+ readonly message: string;
101+ readonly requestedSchema?: Record<string, unknown>;
102+ readonly url?: string;
103+ readonly elicitationId?: string;
104+ readonly createdAt: number;
105+}
106+
107+const knownElicitations = new Map<string, PendingElicitation>();
108+const knownElicitationIdsByRequest = new Map<string, Set<string>>();
109+const formModalSessions = new Map<string, FormModalSession>();
47110
48111 const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [
49112 new SlashCommandBuilder()
@@ -64,10 +127,7 @@ const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [ · 14 unmodified lines
64127 .toJSON(),
65128 new SlashCommandBuilder()
66129 .setName("link-anon")
67− .setDescription("Link to an anonymous MCP session")
68− .addStringOption((option) => option
69− .setName("session_id")
70− .setDescription("Optional session id to reuse"))
130+ .setDescription("Link to an anonymous MCP API key")
71131 .toJSON(),
72132 new SlashCommandBuilder()
73133 .setName("unlink")
@@ -167,13 +227,14 @@ async function linkWorkos(identity: ChatIdentity, workspaceId: string, accessTok · 93 unmodified lines
167227 return data.context;
168228 }
169229
170−async function linkAnonymous(identity: ChatIdentity, sessionId?: string) {
230+async function linkAnonymous(identity: ChatIdentity, workspaceId: string, apiKey: string) {
171231 const data = await unwrap(
172232 api.api.context.link.post({
173233 platform: identity.platform,
174234 userId: identity.userId,
175235 provider: "anonymous",
176− sessionId,
236+ workspaceId,
237+ apiKey,
177238 }),
178239 );
179240 return data.context;
@@ -189,30 +250,624 @@ async function unlink(identity: ChatIdentity) { · 9 unmodified lines
189250 return data.removed;
190251 }
191252
192−async function runPrompt(identity: ChatIdentity, prompt: string) {
253+async function runPrompt(identity: ChatIdentity, prompt: string, requestId?: string) {
193254 const data = await unwrap(
194255 api.api.chat.run.post({
195256 platform: identity.platform,
196257 userId: identity.userId,
197258 prompt,
259+ requestId,
198260 }),
199261 );
200262 return data;
201263 }
202264
265+async function getPendingElicitation(identity: ChatIdentity, requestId: string): Promise<PendingElicitation | null> {
266+ const data = await unwrap(
267+ api.api.chat.elicitation.pending.post({
268+ platform: identity.platform,
269+ userId: identity.userId,
270+ requestId,
271+ }),
272+ );
273+ return data.elicitation;
274+}
275+
276+async function respondToElicitation(
277+ identity: ChatIdentity,
278+ requestId: string,
279+ elicitationRequestId: string,
280+ action: ElicitationAction,
281+ content?: Record<string, unknown>,
282+) {
283+ return await unwrap(
284+ api.api.chat.elicitation.respond.post({
285+ platform: identity.platform,
286+ userId: identity.userId,
287+ requestId,
288+ elicitationRequestId,
289+ action,
290+ content,
291+ }),
292+ );
293+}
294+
295+function randomRequestId(): string {
296+ return crypto.randomUUID();
297+}
298+
299+function elicitationKey(requestId: string, elicitationId: string): string {
300+ return `${requestId}:${elicitationId}`;
301+}
302+
303+function rememberElicitation(elicitation: PendingElicitation) {
304+ knownElicitations.set(elicitationKey(elicitation.requestId, elicitation.id), elicitation);
305+ const existing = knownElicitationIdsByRequest.get(elicitation.requestId) ?? new Set<string>();
306+ existing.add(elicitation.id);
307+ knownElicitationIdsByRequest.set(elicitation.requestId, existing);
308+}
309+
310+function forgetElicitation(requestId: string, elicitationId: string) {
311+ knownElicitations.delete(elicitationKey(requestId, elicitationId));
312+ const ids = knownElicitationIdsByRequest.get(requestId);
313+ if (!ids) {
314+ return;
315+ }
316+ ids.delete(elicitationId);
317+ if (ids.size === 0) {
318+ knownElicitationIdsByRequest.delete(requestId);
319+ }
320+}
321+
322+function forgetRequestElicitations(requestId: string) {
323+ const ids = knownElicitationIdsByRequest.get(requestId);
324+ if (!ids) {
325+ return;
326+ }
327+ for (const id of ids) {
328+ knownElicitations.delete(elicitationKey(requestId, id));
329+ }
330+ knownElicitationIdsByRequest.delete(requestId);
331+}
332+
333+function getKnownElicitation(requestId: string, elicitationId: string): PendingElicitation | null {
334+ return knownElicitations.get(elicitationKey(requestId, elicitationId)) ?? null;
335+}
336+
337+function buildElicitationButtonId(action: ElicitationButtonAction, requestId: string, elicitationId: string): string {
338+ return `${ELICITATION_BUTTON_PREFIX}:${action}:${requestId}:${elicitationId}`;
339+}
340+
341+function parseElicitationButtonId(customId: string): {
342+ action: ElicitationButtonAction;
343+ requestId: string;
344+ elicitationRequestId: string;
345+} | null {
346+ const [prefix, action, requestId, elicitationRequestId] = customId.split(":");
347+ if (prefix !== ELICITATION_BUTTON_PREFIX) {
348+ return null;
349+ }
350+ if (action !== "accept" && action !== "decline" && action !== "cancel" && action !== "form") {
351+ return null;
352+ }
353+ if (!requestId || !elicitationRequestId) {
354+ return null;
355+ }
356+ return {
357+ action,
358+ requestId,
359+ elicitationRequestId,
360+ };
361+}
362+
363+function parseObject(value: unknown): Record<string, unknown> | null {
364+ if (!value || typeof value !== "object" || Array.isArray(value)) {
365+ return null;
366+ }
367+ return value as Record<string, unknown>;
368+}
369+
370+function parseEnumOptions(property: Record<string, unknown>): EnumOption[] | null {
371+ const enumValues = Array.isArray(property.enum)
372+ ? property.enum.filter((value) => ["string", "number", "boolean"].includes(typeof value)) as Array<string | number | boolean>
373+ : null;
374+ if (enumValues && enumValues.length > 0) {
375+ return enumValues.map((value) => ({ value }));
376+ }
377+
378+ const variantsRaw = Array.isArray(property.oneOf)
379+ ? property.oneOf
380+ : Array.isArray(property.anyOf)
381+ ? property.anyOf
382+ : null;
383+
384+ if (!variantsRaw || variantsRaw.length === 0) {
385+ return null;
386+ }
387+
388+ const variants: EnumOption[] = [];
389+ for (const variant of variantsRaw) {
390+ const parsed = parseObject(variant);
391+ if (!parsed || !("const" in parsed)) {
392+ return null;
393+ }
394+ const value = parsed.const;
395+ if (!["string", "number", "boolean"].includes(typeof value)) {
396+ return null;
397+ }
398+ const title = typeof parsed.title === "string" ? parsed.title : undefined;
399+ variants.push({ value: value as string | number | boolean, title });
400+ }
401+
402+ return variants;
403+}
404+
405+function toFieldSpec(
406+ key: string,
407+ property: Record<string, unknown>,
408+ requiredSet: ReadonlySet<string>,
409+): FormFieldSpec | null {
410+ const type = typeof property.type === "string" ? property.type : undefined;
411+ const options = parseEnumOptions(property);
412+
413+ if (type === "array") {
414+ const items = parseObject(property.items);
415+ const itemOptions = items ? parseEnumOptions(items) : null;
416+ if (!itemOptions || itemOptions.length === 0) {
417+ return null;
418+ }
419+ return {
420+ key,
421+ label: typeof property.title === "string" ? property.title.slice(0, 45) : key.slice(0, 45),
422+ description: typeof property.description === "string" ? property.description : undefined,
423+ required: requiredSet.has(key),
424+ kind: "enum_multi",
425+ options: itemOptions,
426+ minItems: typeof property.minItems === "number" ? property.minItems : undefined,
427+ maxItems: typeof property.maxItems === "number" ? property.maxItems : undefined,
428+ defaultValue: Array.isArray(property.default)
429+ ? property.default.filter((value) => ["string", "number", "boolean"].includes(typeof value)) as Array<string | number | boolean>
430+ : undefined,
431+ };
432+ }
433+
434+ const kind: FormFieldKind | null = (() => {
435+ if (options && options.length > 0) {
436+ return "enum_single";
437+ }
438+ if (type === "string") return "string";
439+ if (type === "number") return "number";
440+ if (type === "integer") return "integer";
441+ if (type === "boolean") return "boolean";
442+ return null;
443+ })();
444+
445+ if (!kind) {
446+ return null;
447+ }
448+
449+ const defaultValue = ["string", "number", "boolean"].includes(typeof property.default)
450+ ? property.default as string | number | boolean
451+ : undefined;
452+
453+ return {
454+ key,
455+ label: typeof property.title === "string" ? property.title.slice(0, 45) : key.slice(0, 45),
456+ description: typeof property.description === "string" ? property.description : undefined,
457+ required: requiredSet.has(key),
458+ kind,
459+ minLength: typeof property.minLength === "number" ? property.minLength : undefined,
460+ maxLength: typeof property.maxLength === "number" ? property.maxLength : undefined,
461+ pattern: typeof property.pattern === "string" ? property.pattern : undefined,
462+ minimum: typeof property.minimum === "number" ? property.minimum : undefined,
463+ maximum: typeof property.maximum === "number" ? property.maximum : undefined,
464+ options: options ?? undefined,
465+ defaultValue,
466+ };
467+}
468+
469+function parseFormSchema(requestedSchema?: Record<string, unknown>): FormSchemaSpec {
470+ const schema = parseObject(requestedSchema);
471+ if (!schema) {
472+ return { fields: [], error: "Missing or invalid form schema." };
473+ }
474+
475+ if (schema.type !== "object") {
476+ return { fields: [], error: "Form schema must be an object." };
477+ }
478+
479+ const properties = parseObject(schema.properties);
480+ if (!properties) {
481+ return { fields: [], error: "Form schema is missing properties." };
482+ }
483+
484+ const requiredSet = new Set(
485+ Array.isArray(schema.required)
486+ ? schema.required.filter((value): value is string => typeof value === "string")
487+ : [],
488+ );
489+
490+ const keys = Object.keys(properties);
491+ if (keys.length === 0) {
492+ return { fields: [], error: "Form schema has no fields." };
493+ }
494+ if (keys.length > MAX_DISCORD_MODAL_FIELDS) {
495+ return {
496+ fields: [],
497+ error: `Discord modals support up to ${MAX_DISCORD_MODAL_FIELDS} fields, but schema requested ${keys.length}.`,
498+ };
499+ }
500+
501+ const fields: FormFieldSpec[] = [];
502+ for (const key of keys) {
503+ const property = parseObject(properties[key]);
504+ if (!property) {
505+ return { fields: [], error: `Field '${key}' has an invalid schema.` };
506+ }
507+
508+ const spec = toFieldSpec(key, property, requiredSet);
509+ if (!spec) {
510+ return { fields: [], error: `Field '${key}' uses an unsupported schema shape for Discord input.` };
511+ }
512+ fields.push(spec);
513+ }
514+
515+ return { fields };
516+}
517+
518+function enumOptionLabel(option: EnumOption): string {
519+ if (option.title && option.title.trim().length > 0) {
520+ return `${option.title} (${String(option.value)})`;
521+ }
522+ return String(option.value);
523+}
524+
525+function toTextInputDefault(field: FormFieldSpec): string | undefined {
526+ if (field.defaultValue === undefined) {
527+ return undefined;
528+ }
529+ if (Array.isArray(field.defaultValue)) {
530+ return field.defaultValue.map((value) => String(value)).join(", ");
531+ }
532+ return String(field.defaultValue);
533+}
534+
535+function toTextInputPlaceholder(field: FormFieldSpec): string | undefined {
536+ const optionsHint = field.options && field.options.length > 0
537+ ? `Options: ${field.options.map(enumOptionLabel).join(", ")}`
538+ : undefined;
539+ const boolHint = field.kind === "boolean" ? "Enter true/false" : undefined;
540+
541+ const description = field.description?.trim();
542+ const joined = [description, optionsHint, boolHint]
543+ .filter((value): value is string => Boolean(value && value.length > 0))
544+ .join(" | ");
545+
546+ if (!joined) {
547+ return undefined;
548+ }
549+ return joined.slice(0, 100);
550+}
551+
552+function createFormModalSession(elicitation: PendingElicitation, userId: string): {
553+ modal?: ModalBuilder;
554+ error?: string;
555+} {
556+ const parsed = parseFormSchema(elicitation.requestedSchema);
557+ if (parsed.error) {
558+ return { error: parsed.error };
559+ }
560+
561+ const fields = parsed.fields;
562+ if (fields.length === 0) {
563+ return { error: "No supported fields were found in this form." };
564+ }
565+
566+ const modalId = `${ELICITATION_FORM_MODAL_PREFIX}:${crypto.randomUUID()}`;
567+ const modal = new ModalBuilder()
568+ .setCustomId(modalId)
569+ .setTitle("MCP Elicitation Form");
570+
571+ fields.forEach((field, index) => {
572+ const input = new TextInputBuilder()
573+ .setCustomId(`f_${index}`)
574+ .setLabel(field.label)
575+ .setStyle(TextInputStyle.Short)
576+ .setRequired(field.required);
577+
578+ const defaultValue = toTextInputDefault(field);
579+ if (defaultValue) {
580+ input.setValue(defaultValue.slice(0, 4000));
581+ }
582+
583+ const placeholder = toTextInputPlaceholder(field);
584+ if (placeholder) {
585+ input.setPlaceholder(placeholder);
586+ }
587+
588+ if (field.kind === "string" && (field.maxLength ?? 0) > 120) {
589+ input.setStyle(TextInputStyle.Paragraph);
590+ }
591+
592+ modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input));
593+ });
594+
595+ const timeout = setTimeout(() => {
596+ formModalSessions.delete(modalId);
597+ }, ELICITATION_FORM_SESSION_TIMEOUT_MS);
598+
599+ formModalSessions.set(modalId, {
600+ requestId: elicitation.requestId,
601+ elicitationRequestId: elicitation.id,
602+ userId,
603+ fields,
604+ timeout,
605+ });
606+
607+ return { modal };
608+}
609+
610+function consumeFormModalSession(modalId: string): FormModalSession | null {
611+ const session = formModalSessions.get(modalId);
612+ if (!session) {
613+ return null;
614+ }
615+ clearTimeout(session.timeout);
616+ formModalSessions.delete(modalId);
617+ return session;
618+}
619+
620+function parseBooleanInput(raw: string): boolean | null {
621+ const normalized = raw.trim().toLowerCase();
622+ if (["true", "yes", "y", "1", "on"].includes(normalized)) {
623+ return true;
624+ }
625+ if (["false", "no", "n", "0", "off"].includes(normalized)) {
626+ return false;
627+ }
628+ return null;
629+}
630+
631+function parseEnumValue(input: string, field: FormFieldSpec): { value?: string | number | boolean; error?: string } {
632+ const options = field.options ?? [];
633+ const trimmed = input.trim();
634+ if (trimmed.length === 0) {
635+ return { error: `Field '${field.key}' cannot be empty.` };
636+ }
637+
638+ const matching = options.find((option) => {
639+ const valueText = String(option.value);
640+ const titleText = option.title ?? "";
641+ return valueText === trimmed || titleText.toLowerCase() === trimmed.toLowerCase();
642+ });
643+
644+ if (!matching) {
645+ return {
646+ error: `Field '${field.key}' must match one of: ${options.map(enumOptionLabel).join(", ")}`,
647+ };
648+ }
649+
650+ return { value: matching.value };
651+}
652+
653+function parseFormFieldValue(field: FormFieldSpec, rawInput: string): { value?: unknown; error?: string; skip?: true } {
654+ const raw = rawInput.trim();
655+ if (raw.length === 0) {
656+ if (field.required) {
657+ return { error: `Field '${field.key}' is required.` };
658+ }
659+ return { skip: true };
660+ }
661+
662+ switch (field.kind) {
663+ case "string": {
664+ if (field.minLength !== undefined && raw.length < field.minLength) {
665+ return { error: `Field '${field.key}' must be at least ${field.minLength} characters.` };
666+ }
667+ if (field.maxLength !== undefined && raw.length > field.maxLength) {
668+ return { error: `Field '${field.key}' must be at most ${field.maxLength} characters.` };
669+ }
670+ if (field.pattern) {
671+ const regex = new RegExp(field.pattern);
672+ if (!regex.test(raw)) {
673+ return { error: `Field '${field.key}' does not match required format.` };
674+ }
675+ }
676+ return { value: raw };
677+ }
678+
679+ case "number":
680+ case "integer": {
681+ const parsed = Number(raw);
682+ if (!Number.isFinite(parsed)) {
683+ return { error: `Field '${field.key}' must be a number.` };
684+ }
685+ if (field.kind === "integer" && !Number.isInteger(parsed)) {
686+ return { error: `Field '${field.key}' must be an integer.` };
687+ }
688+ if (field.minimum !== undefined && parsed < field.minimum) {
689+ return { error: `Field '${field.key}' must be >= ${field.minimum}.` };
690+ }
691+ if (field.maximum !== undefined && parsed > field.maximum) {
692+ return { error: `Field '${field.key}' must be <= ${field.maximum}.` };
693+ }
694+ return { value: parsed };
695+ }
696+
697+ case "boolean": {
698+ const parsed = parseBooleanInput(raw);
699+ if (parsed === null) {
700+ return { error: `Field '${field.key}' must be true/false.` };
701+ }
702+ return { value: parsed };
703+ }
704+
705+ case "enum_single": {
706+ return parseEnumValue(raw, field);
707+ }
708+
709+ case "enum_multi": {
710+ const valuesRaw = raw
711+ .split(",")
712+ .map((part) => part.trim())
713+ .filter((part) => part.length > 0);
714+
715+ if (field.minItems !== undefined && valuesRaw.length < field.minItems) {
716+ return { error: `Field '${field.key}' must include at least ${field.minItems} value(s).` };
717+ }
718+ if (field.maxItems !== undefined && valuesRaw.length > field.maxItems) {
719+ return { error: `Field '${field.key}' must include at most ${field.maxItems} value(s).` };
720+ }
721+
722+ const parsed: Array<string | number | boolean> = [];
723+ for (const valueRaw of valuesRaw) {
724+ const value = parseEnumValue(valueRaw, field);
725+ if (value.error) {
726+ return value;
727+ }
728+ parsed.push(value.value!);
729+ }
730+ return { value: parsed };
731+ }
732+ }
733+}
734+
735+function parseFormModalValues(
736+ fields: readonly FormFieldSpec[],
737+ getFieldValue: (fieldId: string) => string,
738+): { content?: Record<string, unknown>; error?: string } {
739+ const content: Record<string, unknown> = {};
740+
741+ for (const [index, field] of fields.entries()) {
742+ const rawInput = getFieldValue(`f_${index}`);
743+ const parsed = parseFormFieldValue(field, rawInput);
744+ if (parsed.error) {
745+ return { error: parsed.error };
746+ }
747+ if (!parsed.skip) {
748+ content[field.key] = parsed.value;
749+ }
750+ }
751+
752+ return { content };
753+}
754+
755+function createElicitationButtons(elicitation: PendingElicitation) {
756+ const row = new ActionRowBuilder<ButtonBuilder>();
757+
758+ if (elicitation.mode === "url") {
759+ if (elicitation.url) {
760+ try {
761+ row.addComponents(
762+ new ButtonBuilder()
763+ .setLabel("Open URL")
764+ .setStyle(ButtonStyle.Link)
765+ .setURL(elicitation.url),
766+ );
767+ } catch {
768+ // Ignore malformed URLs and keep decision buttons available.
769+ }
770+ }
771+
772+ row.addComponents(
773+ new ButtonBuilder()
774+ .setCustomId(buildElicitationButtonId("accept", elicitation.requestId, elicitation.id))
775+ .setLabel("Approve")
776+ .setStyle(ButtonStyle.Success),
777+ );
778+ }
779+
780+ if (elicitation.mode === "form") {
781+ row.addComponents(
782+ new ButtonBuilder()
783+ .setCustomId(buildElicitationButtonId("form", elicitation.requestId, elicitation.id))
784+ .setLabel("Fill Form")
785+ .setStyle(ButtonStyle.Primary),
786+ );
787+ }
788+
789+ row.addComponents(
790+ new ButtonBuilder()
791+ .setCustomId(buildElicitationButtonId("decline", elicitation.requestId, elicitation.id))
792+ .setLabel("Deny")
793+ .setStyle(ButtonStyle.Danger),
794+ new ButtonBuilder()
795+ .setCustomId(buildElicitationButtonId("cancel", elicitation.requestId, elicitation.id))
796+ .setLabel("Cancel")
797+ .setStyle(ButtonStyle.Secondary),
798+ );
799+
800+ return [row];
801+}
802+
803+function formatElicitationMessage(elicitation: PendingElicitation): string {
804+ const lines = [
805+ `MCP elicitation request (${elicitation.mode})`,
806+ elicitation.message,
807+ ];
808+
809+ if (elicitation.mode === "url" && elicitation.url) {
810+ lines.push(`Open URL if approved: ${elicitation.url}`);
811+ }
812+
813+ if (elicitation.mode === "form") {
814+ lines.push("Click Fill Form to provide values, or deny/cancel.");
815+ }
816+
817+ return lines.join("\n");
818+}
819+
820+async function runPromptWithElicitations(
821+ identity: ChatIdentity,
822+ prompt: string,
823+ onElicitation: (elicitation: PendingElicitation) => Promise<void>,
824+) {
825+ const requestId = randomRequestId();
826+ let done = false;
827+ const seen = new Set<string>();
828+
829+ const runPromise = runPrompt(identity, prompt, requestId);
830+ const pollPromise = (async () => {
831+ while (!done) {
832+ try {
833+ const pending = await getPendingElicitation(identity, requestId);
834+ if (pending && !seen.has(pending.id)) {
835+ seen.add(pending.id);
836+ rememberElicitation(pending);
837+ await onElicitation(pending);
838+ }
839+ } catch {
840+ // Best effort polling: do not interrupt active prompt run.
841+ }
842+
843+ await Bun.sleep(ELICITATION_POLL_INTERVAL_MS);
844+ }
845+ })();
846+
847+ try {
848+ return await runPromise;
849+ } finally {
850+ done = true;
851+ await pollPromise.catch(() => {});
852+ forgetRequestElicitations(requestId);
853+ }
854+}
855+
203856 function contextLabel(context: {
204857 source: string;
205858 workspaceId: string;
206859 accountId?: string;
207860 sessionId?: string;
208861 hasAccessToken: boolean;
862+ hasApiKey?: boolean;
209863 }) {
864+ const authMode = context.hasApiKey ? "api-key" : context.hasAccessToken ? "token" : "session";
210865 return [
211866 `source=${context.source}`,
212867 `workspace=${context.workspaceId}`,
213868 context.accountId ? `account=${context.accountId}` : undefined,
214869 context.sessionId ? `session=${context.sessionId}` : undefined,
215− context.hasAccessToken ? "auth=token" : "auth=session",
870+ `auth=${authMode}`,
216871 ]
217872 .filter(Boolean)
218873 .join(" | ");
@@ -253,6 +908,33 @@ function createWorkosLinkModal() { · 34 unmodified lines
253908 return modal;
254909 }
255910
911+function createAnonymousLinkModal() {
912+ const modal = new ModalBuilder()
913+ .setCustomId(ANON_LINK_MODAL_ID)
914+ .setTitle("Link Anonymous MCP Context");
915+
916+ const workspaceInput = new TextInputBuilder()
917+ .setCustomId(ANON_LINK_FIELD_WORKSPACE)
918+ .setLabel("Workspace ID")
919+ .setStyle(TextInputStyle.Short)
920+ .setRequired(true)
921+ .setPlaceholder("Paste workspace id from dashboard");
922+
923+ const apiKeyInput = new TextInputBuilder()
924+ .setCustomId(ANON_LINK_FIELD_API_KEY)
925+ .setLabel("MCP API Key")
926+ .setStyle(TextInputStyle.Paragraph)
927+ .setRequired(true)
928+ .setPlaceholder("Paste API key from dashboard");
929+
930+ modal.addComponents(
931+ new ActionRowBuilder<TextInputBuilder>().addComponents(workspaceInput),
932+ new ActionRowBuilder<TextInputBuilder>().addComponents(apiKeyInput),
933+ );
934+
935+ return modal;
936+}
937+
256938 function buildExecutorLink(identity: ChatIdentity): string | null {
257939 try {
258940 const url = new URL(EXECUTOR_WEB_URL);
@@ -329,9 +1011,9 @@ async function handlePrefixCommand(message: Message<boolean>, rawText: string) { · 70 unmodified lines
3291011 "- /ask <prompt>",
3301012 "- /whoami",
3311013 "- /link-workos (opens secure modal)",
332− "- /link-anon [session_id]",
1014+ "- /link-anon (paste workspace + API key)",
3331015 "- /unlink",
334− `Prefix fallback: ${COMMAND_PREFIX}whoami, ${COMMAND_PREFIX}link-anon [sessionId], ${COMMAND_PREFIX}unlink`,
1016+ `Prefix fallback: ${COMMAND_PREFIX}whoami, ${COMMAND_PREFIX}link-anon <workspaceId> <apiKey>, ${COMMAND_PREFIX}unlink`,
3351017 ].join("\n"));
3361018 return;
3371019 }
@@ -349,8 +1031,12 @@ async function handlePrefixCommand(message: Message<boolean>, rawText: string) { · 11 unmodified lines
3491031 }
3501032
3511033 if (command === "link-anon") {
352− const [sessionId] = args;
353− const context = await linkAnonymous(identity, sessionId);
1034+ const [workspaceId, apiKey] = args;
1035+ if (!workspaceId || !apiKey) {
1036+ await reply(message, `Usage: ${COMMAND_PREFIX}link-anon <workspaceId> <apiKey>`);
1037+ return;
1038+ }
1039+ const context = await linkAnonymous(identity, workspaceId, apiKey);
3541040 await reply(message, `Linked anonymous context: ${contextLabel(context)}`);
3551041 return;
3561042 }
@@ -381,7 +1067,13 @@ bot.on("interactionCreate", async (interaction) => { · 24 unmodified lines
3811067 if (interaction.commandName === "ask") {
3821068 const prompt = interaction.options.getString("prompt", true);
3831069 await interaction.deferReply();
384− const result = await runPrompt(identity, prompt);
1070+ const result = await runPromptWithElicitations(identity, prompt, async (elicitation) => {
1071+ await interaction.followUp({
1072+ content: formatElicitationMessage(elicitation),
1073+ components: createElicitationButtons(elicitation),
1074+ ephemeral: true,
1075+ });
1076+ });
3851077 const chunks = chunkMessage(result.text || "Done.");
3861078 await interaction.editReply(chunks[0] ?? "Done.");
3871079 for (let i = 1; i < chunks.length; i += 1) {
@@ -409,12 +1101,7 @@ bot.on("interactionCreate", async (interaction) => { · 21 unmodified lines
4091101 }
4101102
4111103 if (interaction.commandName === "link-anon") {
412− const sessionId = interaction.options.getString("session_id") ?? undefined;
413− const context = await linkAnonymous(identity, sessionId);
414− await interaction.reply({
415− content: `Linked anonymous context: ${contextLabel(context)}`,
416− ephemeral: true,
417− });
1104+ await interaction.showModal(createAnonymousLinkModal());
4181105 return;
4191106 }
4201107
@@ -452,6 +1139,141 @@ bot.on("interactionCreate", async (interaction) => { · 31 unmodified lines
4521139 return;
4531140 }
4541141
1142+ if (interaction.isModalSubmit() && interaction.customId === ANON_LINK_MODAL_ID) {
1143+ const identity = identityFromUser(interaction.user.id);
1144+ const workspaceId = interaction.fields.getTextInputValue(ANON_LINK_FIELD_WORKSPACE).trim();
1145+ const apiKey = interaction.fields.getTextInputValue(ANON_LINK_FIELD_API_KEY).trim();
1146+
1147+ if (!workspaceId || !apiKey) {
1148+ await interaction.reply({
1149+ content: "Workspace ID and API key are required.",
1150+ ephemeral: true,
1151+ });
1152+ return;
1153+ }
1154+
1155+ await interaction.deferReply({ ephemeral: true });
1156+ const context = await linkAnonymous(identity, workspaceId, apiKey);
1157+ await interaction.editReply(`Linked anonymous context: ${contextLabel(context)}`);
1158+ return;
1159+ }
1160+
1161+ if (interaction.isModalSubmit() && interaction.customId.startsWith(`${ELICITATION_FORM_MODAL_PREFIX}:`)) {
1162+ const session = consumeFormModalSession(interaction.customId);
1163+ if (!session) {
1164+ await interaction.reply({
1165+ content: "This elicitation form has expired. Click Fill Form again.",
1166+ ephemeral: true,
1167+ });
1168+ return;
1169+ }
1170+
1171+ if (session.userId !== interaction.user.id) {
1172+ await interaction.reply({
1173+ content: "This form belongs to a different user.",
1174+ ephemeral: true,
1175+ });
1176+ return;
1177+ }
1178+
1179+ const parsed = parseFormModalValues(
1180+ session.fields,
1181+ (fieldId) => interaction.fields.getTextInputValue(fieldId),
1182+ );
1183+ if (parsed.error || !parsed.content) {
1184+ await interaction.reply({
1185+ content: parsed.error ?? "Invalid form data. Click Fill Form and try again.",
1186+ ephemeral: true,
1187+ });
1188+ return;
1189+ }
1190+
1191+ await interaction.deferReply({ ephemeral: true });
1192+ const identity = identityFromUser(interaction.user.id);
1193+ const resolution = await respondToElicitation(
1194+ identity,
1195+ session.requestId,
1196+ session.elicitationRequestId,
1197+ "accept",
1198+ parsed.content,
1199+ );
1200+
1201+ if (!resolution.ok) {
1202+ await interaction.editReply(resolution.error ?? "Unable to resolve elicitation.");
1203+ return;
1204+ }
1205+
1206+ forgetElicitation(session.requestId, session.elicitationRequestId);
1207+ await interaction.editReply("Elicitation accepted.");
1208+ return;
1209+ }
1210+
1211+ if (interaction.isButton() && interaction.customId.startsWith(`${ELICITATION_BUTTON_PREFIX}:`)) {
1212+ const parsed = parseElicitationButtonId(interaction.customId);
1213+ if (!parsed) {
1214+ await interaction.reply({
1215+ content: "Invalid elicitation action.",
1216+ ephemeral: true,
1217+ });
1218+ return;
1219+ }
1220+
1221+ if (parsed.action === "form") {
1222+ const elicitation = getKnownElicitation(parsed.requestId, parsed.elicitationRequestId);
1223+ if (!elicitation) {
1224+ await interaction.reply({
1225+ content: "This elicitation request is no longer available.",
1226+ ephemeral: true,
1227+ });
1228+ return;
1229+ }
1230+
1231+ if (elicitation.mode !== "form") {
1232+ await interaction.reply({
1233+ content: "This elicitation is not a form request.",
1234+ ephemeral: true,
1235+ });
1236+ return;
1237+ }
1238+
1239+ const modal = createFormModalSession(elicitation, interaction.user.id);
1240+ if (!modal.modal) {
1241+ await interaction.reply({
1242+ content: modal.error ?? "Unable to render this form in Discord.",
1243+ ephemeral: true,
1244+ });
1245+ return;
1246+ }
1247+
1248+ await interaction.showModal(modal.modal);
1249+ return;
1250+ }
1251+
1252+ const identity = identityFromUser(interaction.user.id);
1253+ const resolution = await respondToElicitation(
1254+ identity,
1255+ parsed.requestId,
1256+ parsed.elicitationRequestId,
1257+ parsed.action,
1258+ );
1259+
1260+ if (!resolution.ok) {
1261+ await interaction.reply({
1262+ content: resolution.error ?? "Unable to resolve elicitation.",
1263+ ephemeral: true,
1264+ });
1265+ return;
1266+ }
1267+
1268+ forgetElicitation(parsed.requestId, parsed.elicitationRequestId);
1269+
1270+ await interaction.update({
1271+ content: `Elicitation ${parsed.action}.`,
1272+ components: [],
1273+ });
1274+ return;
1275+ }
1276+
4551277 if (interaction.isButton() && interaction.customId === LINK_OPEN_MODAL_BUTTON_ID) {
4561278 await interaction.showModal(createWorkosLinkModal());
4571279 return;
@@ -504,7 +1326,13 @@ bot.on("messageCreate", async (message) => { · 46 unmodified lines
5041326 }
5051327
5061328 await message.channel.sendTyping();
507− const result = await runPrompt(identityFromUser(message.author.id), prompt);
1329+ const identity = identityFromUser(message.author.id);
1330+ const result = await runPromptWithElicitations(identity, prompt, async (elicitation) => {
1331+ await message.reply({
1332+ content: formatElicitationMessage(elicitation),
1333+ components: createElicitationButtons(elicitation),
1334+ });
1335+ });
5081336 await reply(message, result.text || "Done.");
5091337 } catch (error) {
5101338 const messageText = error instanceof Error ? error.message : String(error);
assistant/packages/core/src/agent.ts+92−6
@@ -4,6 +4,10 @@ · 3 unmodified lines
44
55 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
66 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7+import {
8+ ElicitRequestSchema,
9+ ElicitationCompleteNotificationSchema,
10+} from "@modelcontextprotocol/sdk/types.js";
711 import {
812 completeSimple,
913 getModel,
@@ -51,6 +55,23 @@ interface Message { · 41 unmodified lines
5155 toolCallId?: string;
5256 }
5357
58+export interface AgentElicitationRequest {
59+ readonly mode: "form" | "url";
60+ readonly message: string;
61+ readonly requestedSchema?: Record<string, unknown>;
62+ readonly url?: string;
63+ readonly elicitationId?: string;
64+}
65+
66+export interface AgentElicitationResponse {
67+ readonly action: "accept" | "decline" | "cancel";
68+ readonly content?: Record<string, unknown>;
69+}
70+
71+export interface AgentElicitationCompleteNotification {
72+ readonly elicitationId: string;
73+}
74+
5475 interface McpTool {
5576 name: string;
5677 description?: string;
@@ -81,10 +102,14 @@ export interface AgentOptions { · 24 unmodified lines
81102 readonly clientId?: string;
82103 readonly sessionId?: string;
83104 readonly mcpAccessToken?: string;
105+ readonly mcpApiKey?: string;
106+ readonly useAnonymousMcp?: boolean;
84107 readonly apiKey?: string;
85108 readonly modelId?: string;
86109 readonly context?: string;
87110 readonly maxToolCalls?: number;
111+ readonly onElicitation?: (request: AgentElicitationRequest) => Promise<AgentElicitationResponse>;
112+ readonly onElicitationComplete?: (notification: AgentElicitationCompleteNotification) => void;
88113 }
89114
90115 export interface AgentResult {
@@ -156,10 +181,14 @@ export function createAgent(options: AgentOptions) { · 65 unmodified lines
156181 clientId,
157182 sessionId,
158183 mcpAccessToken,
184+ mcpApiKey,
185+ useAnonymousMcp = false,
159186 apiKey,
160187 modelId = "claude-sonnet-4-5",
161188 context,
162189 maxToolCalls = 20,
190+ onElicitation,
191+ onElicitationComplete,
163192 } = options;
164193
165194 const resolvedApiKey = resolveApiKey(apiKey);
@@ -194,17 +223,23 @@ export function createAgent(options: AgentOptions) { · 28 unmodified lines
194223
195224 // Connect to executor MCP
196225 emit({ type: "status", message: "Connecting..." });
197− const mcpUrl = new URL(`${executorBaseUrl}/mcp`);
226+ const mcpPath = useAnonymousMcp ? "/mcp/anonymous" : "/mcp";
227+ const mcpUrl = new URL(`${executorBaseUrl}${mcpPath}`);
198228 mcpUrl.searchParams.set("workspaceId", workspaceId);
199− if (accountId) mcpUrl.searchParams.set("accountId", accountId);
229+ if (!useAnonymousMcp && accountId) mcpUrl.searchParams.set("accountId", accountId);
200230 if (clientId) mcpUrl.searchParams.set("clientId", clientId);
201− if (sessionId) mcpUrl.searchParams.set("sessionId", sessionId);
231+ if (!useAnonymousMcp && sessionId) mcpUrl.searchParams.set("sessionId", sessionId);
202232
203− const transport = mcpAccessToken
233+ const transport = (mcpAccessToken || mcpApiKey)
204234 ? new StreamableHTTPClientTransport(mcpUrl, {
205235 fetch: async (input, init) => {
206236 const headers = new Headers(init?.headers);
207− headers.set("authorization", `Bearer ${mcpAccessToken}`);
237+ if (mcpAccessToken) {
238+ headers.set("authorization", `Bearer ${mcpAccessToken}`);
239+ }
240+ if (mcpApiKey) {
241+ headers.set("x-api-key", mcpApiKey);
242+ }
208243 return await fetch(input, {
209244 ...init,
210245 headers,
@@ -212,7 +247,58 @@ export function createAgent(options: AgentOptions) { · 1 unmodified line
212247 },
213248 })
214249 : new StreamableHTTPClientTransport(mcpUrl);
215− const mcp = new Client({ name: "assistant-agent", version: "0.1.0" });
250+ const mcp = new Client(
251+ { name: "assistant-agent", version: "0.1.0" },
252+ {
253+ capabilities: {
254+ elicitation: {
255+ form: {},
256+ url: {},
257+ },
258+ },
259+ },
260+ );
261+
262+ mcp.setRequestHandler(ElicitRequestSchema, async (request) => {
263+ const params = request.params as {
264+ mode?: "form" | "url";
265+ message: string;
266+ requestedSchema?: Record<string, unknown>;
267+ url?: string;
268+ elicitationId?: string;
269+ };
270+ const mode = params.mode ?? "form";
271+
272+ emit({ type: "status", message: `Awaiting ${mode} elicitation...` });
273+
274+ if (!onElicitation) {
275+ return { action: "decline" as const };
276+ }
277+
278+ const response = await onElicitation({
279+ mode,
280+ message: params.message,
281+ requestedSchema: params.requestedSchema,
282+ url: params.url,
283+ elicitationId: params.elicitationId,
284+ });
285+
286+ if (response.content === undefined) {
287+ return { action: response.action };
288+ }
289+
290+ return {
291+ action: response.action,
292+ content: response.content,
293+ };
294+ });
295+
296+ mcp.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) => {
297+ emit({ type: "status", message: "Elicitation completed on server." });
298+ onElicitationComplete?.({
299+ elicitationId: notification.params.elicitationId,
300+ });
301+ });
216302
217303 let mcpConnected = false;
218304 try {
assistant/packages/core/src/index.ts+7−1
@@ -1,3 +1,9 @@
11 export { createAgent } from "./agent";
2−export type { AgentOptions, AgentResult } from "./agent";
2+export type {
3+ AgentOptions,
4+ AgentResult,
5+ AgentElicitationRequest,
6+ AgentElicitationResponse,
7+ AgentElicitationCompleteNotification,
8+} from "./agent";
39 export type { TaskEvent, TaskStatusEvent, TaskCodeGeneratedEvent, TaskCodeResultEvent, TaskAgentMessageEvent, TaskErrorEvent, TaskCompletedEvent } from "./events";
assistant/packages/server/src/link-store.ts+1−0
@@ -10,6 +10,7 @@ export interface LinkedMcpContext { · 9 unmodified lines
1010 readonly accountId?: string;
1111 readonly sessionId?: string;
1212 readonly accessToken?: string;
13+ readonly mcpApiKey?: string;
1314 readonly clientId?: string;
1415 readonly linkedAt: number;
1516 }
assistant/packages/server/src/routes.ts+288−9
@@ -1,5 +1,9 @@
11 import { Elysia, t } from "elysia";
2−import { createAgent } from "@assistant/core";
2+import {
3+ createAgent,
4+ type AgentElicitationRequest,
5+ type AgentElicitationResponse,
6+} from "@assistant/core";
37 import { ConvexHttpClient } from "convex/browser";
48 import { api } from "@executor/database/convex/_generated/api";
59 import type { Id } from "@executor/database/convex/_generated/dataModel";
@@ -24,10 +28,37 @@ interface ResolvedExecutorContext { · 18 unmodified lines
2428 readonly sessionId?: string;
2529 readonly clientId?: string;
2630 readonly mcpAccessToken?: string;
31+ readonly mcpApiKey?: string;
32+ readonly mcpApiKeyError?: string;
33+ readonly useAnonymousMcp: boolean;
2734 readonly linked: boolean;
2835 readonly source: "anonymous" | "linked_anonymous" | "linked_workos";
2936 }
3037
38+interface PendingElicitation {
39+ readonly id: string;
40+ readonly identityKey: string;
41+ readonly requestId: string;
42+ readonly mode: "form" | "url";
43+ readonly message: string;
44+ readonly requestedSchema?: Record<string, unknown>;
45+ readonly url?: string;
46+ readonly elicitationId?: string;
47+ readonly createdAt: number;
48+}
49+
50+interface PendingElicitationEntry extends PendingElicitation {
51+ readonly resolve: (response: AgentElicitationResponse) => void;
52+ readonly timeout: ReturnType<typeof setTimeout>;
53+}
54+
55+interface ResolvePendingElicitationResult {
56+ readonly ok: boolean;
57+ readonly error?: string;
58+}
59+
60+const ELICITATION_TIMEOUT_MS = 10 * 60 * 1000;
61+
3162 function normalizeIdentityPart(value: string): string {
3263 return value
3364 .toLowerCase()
@@ -51,6 +82,142 @@ export function createApp(options: ServerOptions) { · 17 unmodified lines
5182 const convex = new ConvexHttpClient(options.convexUrl);
5283 const linkStore = createFileLinkStore(options.linksFile);
5384 const defaultClientId = options.defaultClientId?.trim() || "assistant-chat";
85+ const pendingElicitations = new Map<string, PendingElicitationEntry>();
86+ const pendingByIdentity = new Map<string, string[]>();
87+
88+ function removePendingFromQueue(identity: string, id: string) {
89+ const existing = pendingByIdentity.get(identity);
90+ if (!existing || existing.length === 0) {
91+ return;
92+ }
93+
94+ const next = existing.filter((candidate) => candidate !== id);
95+ if (next.length === 0) {
96+ pendingByIdentity.delete(identity);
97+ return;
98+ }
99+ pendingByIdentity.set(identity, next);
100+ }
101+
102+ function nextPending(identity: ChatIdentity, requestId?: string): PendingElicitation | null {
103+ const key = identityKey(identity);
104+ const queue = pendingByIdentity.get(key);
105+ if (!queue || queue.length === 0) {
106+ return null;
107+ }
108+
109+ while (queue.length > 0) {
110+ const id = queue[0]!;
111+ const entry = pendingElicitations.get(id);
112+ if (!entry) {
113+ queue.shift();
114+ continue;
115+ }
116+
117+ if (requestId && entry.requestId !== requestId) {
118+ const index = queue.findIndex((candidate) => {
119+ const candidateEntry = pendingElicitations.get(candidate);
120+ return Boolean(candidateEntry) && candidateEntry?.requestId === requestId;
121+ });
122+ if (index === -1) {
123+ return null;
124+ }
125+ const candidateId = queue[index]!;
126+ const candidateEntry = pendingElicitations.get(candidateId);
127+ return candidateEntry ?? null;
128+ }
129+
130+ return entry;
131+ }
132+
133+ pendingByIdentity.delete(key);
134+ return null;
135+ }
136+
137+ async function waitForElicitationResponse(
138+ identity: ChatIdentity,
139+ requestId: string,
140+ request: AgentElicitationRequest,
141+ ): Promise<AgentElicitationResponse> {
142+ const key = identityKey(identity);
143+ const id = crypto.randomUUID();
144+ const createdAt = Date.now();
145+
146+ return await new Promise<AgentElicitationResponse>((resolve) => {
147+ const resolveAndCleanup = (response: AgentElicitationResponse) => {
148+ const entry = pendingElicitations.get(id);
149+ if (!entry) {
150+ return;
151+ }
152+ clearTimeout(entry.timeout);
153+ pendingElicitations.delete(id);
154+ removePendingFromQueue(key, id);
155+ resolve(response);
156+ };
157+
158+ const timeout = setTimeout(() => {
159+ resolveAndCleanup({ action: "cancel" });
160+ }, ELICITATION_TIMEOUT_MS);
161+
162+ const entry: PendingElicitationEntry = {
163+ id,
164+ identityKey: key,
165+ requestId,
166+ mode: request.mode,
167+ message: request.message,
168+ requestedSchema: request.requestedSchema,
169+ url: request.url,
170+ elicitationId: request.elicitationId,
171+ createdAt,
172+ resolve: resolveAndCleanup,
173+ timeout,
174+ };
175+
176+ pendingElicitations.set(id, entry);
177+ const queue = pendingByIdentity.get(key) ?? [];
178+ queue.push(id);
179+ pendingByIdentity.set(key, queue);
180+ });
181+ }
182+
183+ function resolvePendingElicitation(
184+ identity: ChatIdentity,
185+ args: {
186+ requestId?: string;
187+ elicitationRequestId: string;
188+ action: AgentElicitationResponse["action"];
189+ content?: Record<string, unknown>;
190+ },
191+ ): ResolvePendingElicitationResult {
192+ const entry = pendingElicitations.get(args.elicitationRequestId);
193+ if (!entry) {
194+ return { ok: false, error: "Elicitation request not found or already resolved." };
195+ }
196+
197+ const key = identityKey(identity);
198+ if (entry.identityKey !== key) {
199+ return { ok: false, error: "Elicitation request does not belong to this user." };
200+ }
201+ if (args.requestId && entry.requestId !== args.requestId) {
202+ return { ok: false, error: "Elicitation request id does not match the active prompt." };
203+ }
204+
205+ if (entry.mode === "url" && args.action === "accept") {
206+ entry.resolve({ action: "accept" });
207+ return { ok: true };
208+ }
209+
210+ if (entry.mode === "form" && args.action === "accept") {
211+ if (!args.content) {
212+ return { ok: false, error: "Form elicitation requires content." };
213+ }
214+ entry.resolve({ action: "accept", content: args.content });
215+ return { ok: true };
216+ }
217+
218+ entry.resolve({ action: args.action });
219+ return { ok: true };
220+ }
54221
55222 async function resolveContext(identity: ChatIdentity): Promise<ResolvedExecutorContext> {
56223 const key = identityKey(identity);
@@ -63,17 +230,40 @@ export function createApp(options: ServerOptions) { · 6 unmodified lines
63230 sessionId: linked.sessionId,
64231 clientId: linked.clientId ?? defaultClientId,
65232 mcpAccessToken: linked.accessToken,
233+ useAnonymousMcp: false,
66234 linked: true,
67235 source: "linked_workos",
68236 };
69237 }
70238
239+ if (linked?.provider === "anonymous" && linked.mcpApiKey) {
240+ return {
241+ workspaceId: linked.workspaceId,
242+ accountId: linked.accountId,
243+ sessionId: linked.sessionId,
244+ clientId: linked.clientId ?? defaultClientId,
245+ mcpApiKey: linked.mcpApiKey,
246+ useAnonymousMcp: true,
247+ linked: true,
248+ source: "linked_anonymous",
249+ };
250+ }
251+
71252 const requestedSessionId = linked?.sessionId ?? defaultSessionId(identity);
72253 const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, {
73254 sessionId: requestedSessionId,
74255 });
75256
76257 const clientId = linked?.clientId ?? defaultClientId;
258+ const anonymousMcpApiKey = await convex.query(api.workspace.getMcpApiKey, {
259+ workspaceId: anonymous.workspaceId,
260+ sessionId: anonymous.sessionId,
261+ });
262+
263+ const mcpApiKey = anonymousMcpApiKey?.enabled ? anonymousMcpApiKey.apiKey ?? undefined : undefined;
264+ const mcpApiKeyError = anonymousMcpApiKey?.enabled
265+ ? undefined
266+ : anonymousMcpApiKey?.error ?? "Anonymous MCP API key is unavailable";
77267
78268 if (linked?.provider === "anonymous") {
79269 await linkStore.set(key, {
@@ -81,6 +271,7 @@ export function createApp(options: ServerOptions) { · 1 unmodified line
81271 workspaceId: anonymous.workspaceId,
82272 accountId: anonymous.accountId,
83273 sessionId: anonymous.sessionId,
274+ mcpApiKey,
84275 clientId,
85276 linkedAt: linked.linkedAt,
86277 });
@@ -91,6 +282,9 @@ export function createApp(options: ServerOptions) { · 4 unmodified lines
91282 accountId: anonymous.accountId,
92283 sessionId: anonymous.sessionId,
93284 clientId,
285+ mcpApiKey,
286+ mcpApiKeyError,
287+ useAnonymousMcp: true,
94288 linked: linked?.provider === "anonymous",
95289 source: linked?.provider === "anonymous" ? "linked_anonymous" : "anonymous",
96290 };
@@ -105,6 +299,7 @@ export function createApp(options: ServerOptions) { · 8 unmodified lines
105299 linked: context.linked,
106300 source: context.source,
107301 hasAccessToken: Boolean(context.mcpAccessToken),
302+ hasApiKey: Boolean(context.mcpApiKey),
108303 };
109304 }
110305
@@ -155,16 +350,19 @@ export function createApp(options: ServerOptions) { · 44 unmodified lines
155350 return { linked: true, context: toPublicContext(context) };
156351 }
157352
158− const requestedSessionId = body.sessionId?.trim() || defaultSessionId(identity);
159− const anonymous = await convex.mutation(api.workspace.bootstrapAnonymousSession, {
160− sessionId: requestedSessionId,
161− });
353+ if (!body.workspaceId?.trim()) {
354+ throw new Error("workspaceId is required for anonymous links");
355+ }
356+ if (!body.apiKey?.trim()) {
357+ throw new Error("apiKey is required for anonymous links");
358+ }
162359
163360 const link: LinkedMcpContext = {
164361 provider: "anonymous",
165− workspaceId: anonymous.workspaceId,
166− accountId: anonymous.accountId,
167− sessionId: anonymous.sessionId,
362+ workspaceId: body.workspaceId as Id<"workspaces">,
363+ accountId: body.accountId?.trim() || undefined,
364+ sessionId: body.sessionId?.trim() || undefined,
365+ mcpApiKey: body.apiKey.trim(),
168366 clientId,
169367 linkedAt,
170368 };
@@ -181,6 +379,7 @@ export function createApp(options: ServerOptions) { · 10 unmodified lines
181379 accountId: t.Optional(t.String({ minLength: 1 })),
182380 sessionId: t.Optional(t.String({ minLength: 1 })),
183381 accessToken: t.Optional(t.String({ minLength: 1 })),
382+ apiKey: t.Optional(t.String({ minLength: 1 })),
184383 clientId: t.Optional(t.String({ minLength: 1 })),
185384 }),
186385 },
@@ -200,13 +399,86 @@ export function createApp(options: ServerOptions) { · 13 unmodified lines
200399 },
201400 )
202401
402+ .post(
403+ "/api/chat/elicitation/pending",
404+ async ({ body }) => {
405+ const identity = {
406+ platform: body.platform,
407+ userId: body.userId,
408+ } satisfies ChatIdentity;
409+
410+ const pending = nextPending(identity, body.requestId?.trim() || undefined);
411+ if (!pending) {
412+ return { elicitation: null };
413+ }
414+
415+ return {
416+ elicitation: {
417+ id: pending.id,
418+ requestId: pending.requestId,
419+ mode: pending.mode,
420+ message: pending.message,
421+ requestedSchema: pending.requestedSchema,
422+ url: pending.url,
423+ elicitationId: pending.elicitationId,
424+ createdAt: pending.createdAt,
425+ },
426+ };
427+ },
428+ {
429+ body: t.Object({
430+ platform: t.String({ minLength: 1 }),
431+ userId: t.String({ minLength: 1 }),
432+ requestId: t.Optional(t.String({ minLength: 1 })),
433+ }),
434+ },
435+ )
436+
437+ .post(
438+ "/api/chat/elicitation/respond",
439+ async ({ body }) => {
440+ const identity = {
441+ platform: body.platform,
442+ userId: body.userId,
443+ } satisfies ChatIdentity;
444+
445+ const resolution = resolvePendingElicitation(identity, {
446+ requestId: body.requestId?.trim() || undefined,
447+ elicitationRequestId: body.elicitationRequestId,
448+ action: body.action,
449+ content: body.content,
450+ });
451+
452+ return resolution;
453+ },
454+ {
455+ body: t.Object({
456+ platform: t.String({ minLength: 1 }),
457+ userId: t.String({ minLength: 1 }),
458+ requestId: t.Optional(t.String({ minLength: 1 })),
459+ elicitationRequestId: t.String({ minLength: 1 }),
460+ action: t.Union([t.Literal("accept"), t.Literal("decline"), t.Literal("cancel")]),
461+ content: t.Optional(t.Record(t.String(), t.Unknown())),
462+ }),
463+ },
464+ )
465+
203466 .post(
204467 "/api/chat/run",
205468 async ({ body }) => {
206− const context = await resolveContext({
469+ const identity = {
207470 platform: body.platform,
208471 userId: body.userId,
472+ } satisfies ChatIdentity;
473+ const context = await resolveContext({
474+ platform: identity.platform,
475+ userId: identity.userId,
209476 });
477+ const requestId = body.requestId?.trim() || crypto.randomUUID();
478+
479+ if (context.useAnonymousMcp && !context.mcpApiKey) {
480+ throw new Error(context.mcpApiKeyError ?? "Anonymous MCP API key is unavailable");
481+ }
210482
211483 const agent = createAgent({
212484 executorUrl: options.executorUrl,
@@ -215,11 +487,17 @@ export function createApp(options: ServerOptions) { · 2 unmodified lines
215487 sessionId: context.sessionId,
216488 clientId: context.clientId,
217489 mcpAccessToken: context.mcpAccessToken,
490+ mcpApiKey: context.mcpApiKey,
491+ useAnonymousMcp: context.useAnonymousMcp,
218492 context: options.context,
493+ onElicitation: async (request) => {
494+ return await waitForElicitationResponse(identity, requestId, request);
495+ },
219496 });
220497
221498 const result = await agent.run(body.prompt);
222499 return {
500+ requestId,
223501 text: result.text,
224502 toolCalls: result.toolCalls,
225503 context: toPublicContext(context),
@@ -230,6 +508,7 @@ export function createApp(options: ServerOptions) { · 4 unmodified lines
230508 platform: t.String({ minLength: 1 }),
231509 userId: t.String({ minLength: 1 }),
232510 prompt: t.String({ minLength: 1 }),
511+ requestId: t.Optional(t.String({ minLength: 1 })),
233512 }),
234513 },
235514 );
executor/apps/web/src/components/tasks/task-composer.tsx+7−5
@@ -20,7 +20,7 @@ import { FormattedCodeBlock } from "@/components/formatted/code-block"; · 19 unmodified lines
2020 import { convexApi } from "@/lib/convex-api";
2121 import { useSession } from "@/lib/session-context";
2222 import type { RuntimeTargetDescriptor } from "@/lib/types";
23−import { listRuntimeTargets } from "@/lib/runtime-targets";
23+import { listRuntimeTargetsWithOptions } from "@/lib/runtime-targets";
2424 import { useWorkspaceTools } from "@/hooks/use/workspace-tools";
2525
2626 const DEFAULT_CODE = `// Example: discover tools and return matching tool names
@@ -107,7 +107,7 @@ function formatExecutionValue(value: unknown): string { · 80 unmodified lines
107107 }
108108
109109 export function TaskComposer() {
110− const { context } = useSession();
110+ const { context, clientConfig } = useSession();
111111 const [code, setCode] = useState(() => {
112112 return readCodeDraftForWorkspace(context?.workspaceId) ?? DEFAULT_CODE;
113113 });
@@ -131,7 +131,10 @@ export function TaskComposer() { · 17 unmodified lines
131131 [storageWorkspaceId],
132132 );
133133
134− const runtimeTargets = useMemo(() => listRuntimeTargets(), []);
134+ const runtimeTargets = useMemo(
135+ () => listRuntimeTargetsWithOptions({ allowLocalVm: clientConfig?.runtime?.allowLocalVm }),
136+ [clientConfig?.runtime?.allowLocalVm],
137+ );
135138 const createTask = useAction(convexApi.executor.createTask);
136139 const { tools, typesUrl, loadingTools } = useWorkspaceTools(context ?? null);
137140 const effectiveRuntimeId = runtimeTargets.some((runtime: RuntimeTargetDescriptor) => runtime.id === runtimeId)
@@ -186,7 +189,6 @@ export function TaskComposer() { · 48 unmodified lines
186189 timeoutMs: Number.parseInt(timeoutMs, 10) || DEFAULT_TIMEOUT_MS,
187190 workspaceId: context.workspaceId,
188191 sessionId: context.sessionId,
189− clientId: context.clientId,
190192 waitForResult: true,
191193 });
192194
@@ -221,7 +223,7 @@ export function TaskComposer() { · 28 unmodified lines
221223 <CardContent className="space-y-4">
222224 <div className="grid grid-cols-2 gap-3">
223225 <div className="space-y-1.5">
224− <Label className="text-xs text-muted-foreground">Runtime</Label>
226+ {showRuntimeSelector ? <Label className="text-xs text-muted-foreground">Runtime</Label> : null}
225227 {showRuntimeSelector ? (
226228 <Select value={effectiveRuntimeId} onValueChange={setRuntimeId}>
227229 <SelectTrigger className="h-8 text-xs font-mono bg-background">
executor/apps/web/src/components/tools/policies.tsx+1039−77
@@ -49,10 +49,13 @@ import { useSession } from "@/lib/session-context"; · 48 unmodified lines
4949 import { convexApi } from "@/lib/convex-api";
5050 import { cn } from "@/lib/utils";
5151 import type {
52− AccessPolicyRecord,
52+ ToolPolicyRecord,
5353 ArgumentCondition,
5454 ArgumentConditionOperator,
5555 ToolDescriptor,
56+ ToolRoleBindingRecord,
57+ ToolRoleRuleRecord,
58+ ToolRoleRecord,
5659 } from "@/lib/types";
5760 import type { Id } from "@executor/database/convex/_generated/dataModel";
5861 import { workspaceQueryArgs } from "@/lib/workspace/query-args";
@@ -61,6 +64,7 @@ import { sourceLabel } from "@/lib/tool/source-utils"; · 2 unmodified lines
6164 // ── Types ────────────────────────────────────────────────────────────────────
6265
6366 type PolicyDecisionType = "allow" | "require_approval" | "deny";
67+type PolicyResourceType = ToolPolicyRecord["resourceType"];
6468
6569 interface ToolNamespace {
6670 prefix: string;
@@ -74,28 +78,92 @@ type PolicyScope = "personal" | "workspace" | "organization"; · 7 unmodified lines
7478 interface FormState {
7579 scope: PolicyScope;
7680 decision: PolicyDecisionType;
81+ resourceType: PolicyResourceType;
7782 selectedToolPaths: string[];
7883 resourcePattern: string;
84+ sourcePattern: string;
85+ namespacePattern: string;
7986 argumentConditions: ArgumentCondition[];
8087 clientId: string;
8188 priority: string;
8289 }
8390
91+interface RoleFormState {
92+ name: string;
93+ description: string;
94+}
95+
96+interface RoleRuleFormState {
97+ selectorType: ToolRoleRuleRecord["selectorType"];
98+ sourceKey: string;
99+ resourcePattern: string;
100+ matchType: ToolRoleRuleRecord["matchType"];
101+ effect: ToolRoleRuleRecord["effect"];
102+ approvalMode: ToolRoleRuleRecord["approvalMode"];
103+ priority: string;
104+}
105+
106+interface RoleBindingFormState {
107+ scopeType: ToolRoleBindingRecord["scopeType"];
108+ targetAccountId: string;
109+ clientId: string;
110+ status: ToolRoleBindingRecord["status"];
111+}
112+
113+interface OrganizationMemberListItem {
114+ accountId: string;
115+ displayName: string;
116+ email: string | null;
117+ role: "owner" | "admin" | "member" | "billing_admin";
118+ status: "active" | "pending" | "removed";
119+}
120+
84121 function defaultFormState(): FormState {
85122 return {
86123 scope: "personal",
87124 decision: "require_approval",
125+ resourceType: "tool_path",
88126 selectedToolPaths: [],
89127 resourcePattern: "",
128+ sourcePattern: "",
129+ namespacePattern: "",
90130 argumentConditions: [],
91131 clientId: "",
92132 priority: "100",
93133 };
94134 }
95135
136+function defaultRoleFormState(): RoleFormState {
137+ return {
138+ name: "",
139+ description: "",
140+ };
141+}
142+
143+function defaultRoleRuleFormState(): RoleRuleFormState {
144+ return {
145+ selectorType: "tool_path",
146+ sourceKey: "",
147+ resourcePattern: "",
148+ matchType: "glob",
149+ effect: "allow",
150+ approvalMode: "required",
151+ priority: "100",
152+ };
153+}
154+
155+function defaultRoleBindingFormState(): RoleBindingFormState {
156+ return {
157+ scopeType: "workspace",
158+ targetAccountId: "",
159+ clientId: "",
160+ status: "active",
161+ };
162+}
163+
96164 // ── Helpers ──────────────────────────────────────────────────────────────────
97165
98−function getDecisionFromPolicy(policy: AccessPolicyRecord): PolicyDecisionType {
166+function getDecisionFromPolicy(policy: ToolPolicyRecord): PolicyDecisionType {
99167 if (policy.effect === "deny") return "deny";
100168 return policy.approvalMode === "required" ? "require_approval" : "allow";
101169 }
@@ -106,7 +174,7 @@ function getDecisionPayload(decision: PolicyDecisionType) { · 4 unmodified lines
106174 return { effect: "allow" as const, approvalMode: "auto" as const };
107175 }
108176
109−function scopeLabel(policy: AccessPolicyRecord, currentAccountId?: string): string {
177+function scopeLabel(policy: ToolPolicyRecord, currentAccountId?: string): string {
110178 if (policy.targetAccountId) {
111179 return policy.targetAccountId === currentAccountId ? "personal" : "user";
112180 }
@@ -135,6 +203,28 @@ const DECISION_CONFIG: Record<PolicyDecisionType, { label: string; color: string · 22 unmodified lines
135203 },
136204 };
137205
206+const RESOURCE_TYPE_CONFIG: Record<
207+ PolicyResourceType,
208+ { label: string; description: string }
209+> = {
210+ all_tools: {
211+ label: "All tools",
212+ description: "Apply to every tool in this workspace",
213+ },
214+ source: {
215+ label: "Source",
216+ description: "Apply to all tools from one source",
217+ },
218+ namespace: {
219+ label: "Namespace",
220+ description: "Apply to a namespace prefix pattern",
221+ },
222+ tool_path: {
223+ label: "Tool path",
224+ description: "Apply to specific tools or path patterns",
225+ },
226+};
227+
138228 const OPERATOR_LABELS: Record<ArgumentConditionOperator, string> = {
139229 equals: "equals",
140230 not_equals: "not equals",
@@ -159,13 +249,6 @@ function buildNamespaces(tools: ToolDescriptor[]): ToolNamespace[] { · 18 unmodified lines
159249 return Array.from(nsMap.values()).sort((a, b) => a.prefix.localeCompare(b.prefix));
160250 }
161251
162−function patternMatchesToolPath(pattern: string, toolPath: string): boolean {
163− if (pattern === "*") return true;
164− if (!pattern.includes("*")) return pattern === toolPath;
165− const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
166− return new RegExp(`^${escaped}$`).test(toolPath);
167−}
168−
169252 function derivePatternFromSelection(paths: string[], namespaces: ToolNamespace[]): string {
170253 if (paths.length === 0) return "*";
171254 if (paths.length === 1) return paths[0]!;
@@ -196,6 +279,77 @@ function derivePatternFromSelection(paths: string[], namespaces: ToolNamespace[] · 24 unmodified lines
196279 return paths.join(", ");
197280 }
198281
282+function matchesResourcePattern(pattern: string, candidate: string, matchType: "glob" | "exact"): boolean {
283+ if (matchType === "exact") {
284+ return pattern === candidate;
285+ }
286+ if (pattern === "*") {
287+ return true;
288+ }
289+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
290+ return new RegExp(`^${escaped}$`).test(candidate);
291+}
292+
293+function policyMatchesTool(policy: ToolPolicyRecord, tool: ToolDescriptor): boolean {
294+ if (policy.resourceType === "all_tools") {
295+ return true;
296+ }
297+ if (policy.resourceType === "source") {
298+ if (!tool.source) {
299+ return false;
300+ }
301+ return matchesResourcePattern(policy.resourcePattern, tool.source, policy.matchType);
302+ }
303+
304+ return matchesResourcePattern(policy.resourcePattern, tool.path, policy.matchType);
305+}
306+
307+function resourceTypeLabel(resourceType: ToolPolicyRecord["resourceType"]): string {
308+ if (resourceType === "all_tools") return "all tools";
309+ if (resourceType === "source") return "source";
310+ if (resourceType === "namespace") return "namespace";
311+ return "tool path";
312+}
313+
314+function createToolPolicyId(): string {
315+ return `tool_policy_${crypto.randomUUID()}`;
316+}
317+
318+function toolPolicyRoleId(policyId: string): string {
319+ return `tool_policy_role_${policyId}`;
320+}
321+
322+function toolPolicyRuleId(policyId: string): string {
323+ return `tool_policy_rule_${policyId}`;
324+}
325+
326+function toolPolicyBindingId(policyId: string): string {
327+ return `tool_policy_binding_${policyId}`;
328+}
329+
330+function isDirectToolPolicy(policy: ToolPolicyRecord): boolean {
331+ if (!policy.id.startsWith("tool_policy_")) {
332+ return false;
333+ }
334+
335+ if (!policy.roleId || !policy.ruleId || !policy.bindingId) {
336+ return false;
337+ }
338+
339+ return (
340+ policy.roleId === toolPolicyRoleId(policy.id)
341+ && policy.ruleId === toolPolicyRuleId(policy.id)
342+ && policy.bindingId === toolPolicyBindingId(policy.id)
343+ );
344+}
345+
346+function roleRulePattern(rule: ToolRoleRuleRecord): string {
347+ if (rule.selectorType === "all") return "*";
348+ if (rule.selectorType === "source") return rule.sourceKey ?? "";
349+ if (rule.selectorType === "namespace") return rule.namespacePattern ?? "";
350+ return rule.toolPathPattern ?? "";
351+}
352+
199353 // ── Tool Picker (virtualized) ────────────────────────────────────────────────
200354
201355 type VirtualRow =
@@ -573,12 +727,14 @@ function PolicyCard({ · 371 unmodified lines
573727 tools,
574728 currentAccountId,
575729 onDelete,
730+ canDelete,
576731 deleting,
577732 }: {
578− policy: AccessPolicyRecord;
733+ policy: ToolPolicyRecord;
579734 tools: ToolDescriptor[];
580735 currentAccountId?: string;
581− onDelete: (id: string) => void;
736+ onDelete: (policy: ToolPolicyRecord) => void;
737+ canDelete: boolean;
582738 deleting: boolean;
583739 }) {
584740 const decision = getDecisionFromPolicy(policy);
@@ -586,9 +742,8 @@ function PolicyCard({ · 1 unmodified line
586742 const Icon = config.icon;
587743
588744 const matchingTools = useMemo(() => {
589− if (policy.resourcePattern === "*") return tools;
590− return tools.filter((t) => patternMatchesToolPath(policy.resourcePattern, t.path));
591− }, [policy.resourcePattern, tools]);
745+ return tools.filter((tool) => policyMatchesTool(policy, tool));
746+ }, [policy, tools]);
592747
593748 const [showTools, setShowTools] = useState(false);
594749
@@ -616,6 +771,12 @@ function PolicyCard({ · 21 unmodified lines
616771 >
617772 p{policy.priority}
618773 </Badge>
774+ <Badge
775+ variant="outline"
776+ className="text-[9px] px-1.5 py-0 font-mono uppercase tracking-wider border-border/50"
777+ >
778+ {resourceTypeLabel(policy.resourceType)}
779+ </Badge>
619780 </div>
620781 {/* Pattern display */}
621782 <div className="mt-1">
@@ -663,14 +824,21 @@ function PolicyCard({ · 41 unmodified lines
663824 <TooltipTrigger asChild>
664825 <button
665826 type="button"
666− onClick={() => onDelete(policy.id)}
667− disabled={deleting}
668− className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive shrink-0"
827+ onClick={() => onDelete(policy)}
828+ disabled={deleting || !canDelete}
829+ className={cn(
830+ "opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded shrink-0",
831+ canDelete
832+ ? "hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
833+ : "text-muted-foreground/40 cursor-not-allowed",
834+ )}
669835 >
670836 <Trash2 className="h-3.5 w-3.5" />
671837 </button>
672838 </TooltipTrigger>
673− <TooltipContent side="left" className="text-xs">Delete policy</TooltipContent>
839+ <TooltipContent side="left" className="text-xs">
840+ {canDelete ? "Delete policy" : "Role-managed policy (delete from roles)"}
841+ </TooltipContent>
674842 </Tooltip>
675843 </TooltipProvider>
676844 </div>
@@ -688,33 +856,144 @@ export function PoliciesPanel({ · 11 unmodified lines
688856 tools?: ToolDescriptor[];
689857 loadingTools?: boolean;
690858 }) {
691− const { context } = useSession();
859+ const { context, workspaces } = useSession();
692860 const [dialogOpen, setDialogOpen] = useState(false);
693861 const [submitting, setSubmitting] = useState(false);
694862 const [deletingId, setDeletingId] = useState<string | null>(null);
695863 const [form, setForm] = useState<FormState>(defaultFormState);
864+ const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
865+ const [roleForm, setRoleForm] = useState<RoleFormState>(defaultRoleFormState);
866+ const [roleRuleForm, setRoleRuleForm] = useState<RoleRuleFormState>(defaultRoleRuleFormState);
867+ const [roleBindingForm, setRoleBindingForm] = useState<RoleBindingFormState>(defaultRoleBindingFormState);
868+ const [busyRoleAction, setBusyRoleAction] = useState<string | null>(null);
696869
697870 const listArgs = workspaceQueryArgs(context);
698− const policiesQuery = useQuery(convexApi.workspace.listAccessPolicies, listArgs);
699− const upsertAccessPolicy = useMutation(convexApi.workspace.upsertAccessPolicy);
700− const deleteAccessPolicy = useMutation(convexApi.workspace.deleteAccessPolicy);
871+ const policiesQuery = useQuery(convexApi.workspace.listToolPolicies, listArgs);
872+ const upsertToolRole = useMutation(convexApi.workspace.upsertToolRole);
873+ const upsertToolRoleRule = useMutation(convexApi.workspace.upsertToolRoleRule);
874+ const upsertToolRoleBinding = useMutation(convexApi.workspace.upsertToolRoleBinding);
875+ const deleteToolRole = useMutation(convexApi.workspace.deleteToolRole);
876+ const deleteToolRoleRule = useMutation(convexApi.workspace.deleteToolRoleRule);
877+ const deleteToolRoleBinding = useMutation(convexApi.workspace.deleteToolRoleBinding);
701878
702879 const loading = Boolean(context) && policiesQuery === undefined;
703− const policies = useMemo(() => (policiesQuery ?? []) as AccessPolicyRecord[], [policiesQuery]);
880+ const policies = useMemo(() => (policiesQuery ?? []) as ToolPolicyRecord[], [policiesQuery]);
881+
882+ const currentWorkspace = useMemo(() => {
883+ if (!context) return null;
884+ return workspaces.find((workspace) => workspace.id === context.workspaceId) ?? null;
885+ }, [context, workspaces]);
886+ const currentOrganizationId = currentWorkspace?.organizationId ?? null;
887+
888+ const membersQuery = useQuery(
889+ convexApi.organizationMembers.list,
890+ context && currentOrganizationId
891+ ? { organizationId: currentOrganizationId, sessionId: context.sessionId }
892+ : "skip",
893+ );
894+ const memberItems = useMemo(
895+ () => ((membersQuery?.items ?? []) as OrganizationMemberListItem[]),
896+ [membersQuery],
897+ );
898+ const selfMembership = useMemo(() => {
899+ if (!context) return null;
900+ return memberItems.find((member) => member.accountId === context.accountId) ?? null;
901+ }, [context, memberItems]);
902+ const canManageRoles = selfMembership?.role === "owner" || selfMembership?.role === "admin";
903+
904+ const rolesQuery = useQuery(
905+ convexApi.workspace.listToolRoles,
906+ context && canManageRoles ? listArgs : "skip",
907+ );
908+ const roleItems = useMemo(() => ((rolesQuery ?? []) as ToolRoleRecord[]), [rolesQuery]);
909+ const activeRoleId = useMemo(() => {
910+ if (!canManageRoles) {
911+ return null;
912+ }
913+
914+ if (selectedRoleId && roleItems.some((role) => role.id === selectedRoleId)) {
915+ return selectedRoleId;
916+ }
917+
918+ return roleItems[0]?.id ?? null;
919+ }, [canManageRoles, roleItems, selectedRoleId]);
920+
921+ const bindingsQuery = useQuery(
922+ convexApi.workspace.listToolRoleBindings,
923+ context && canManageRoles ? listArgs : "skip",
924+ );
925+ const bindingItems = useMemo(() => ((bindingsQuery ?? []) as ToolRoleBindingRecord[]), [bindingsQuery]);
926+
927+ const roleRulesQuery = useQuery(
928+ convexApi.workspace.listToolRoleRules,
929+ context && canManageRoles && activeRoleId
930+ ? {
931+ workspaceId: context.workspaceId,
932+ sessionId: context.sessionId,
933+ roleId: activeRoleId,
934+ }
935+ : "skip",
936+ );
937+ const selectedRoleRules = useMemo(
938+ () => ((roleRulesQuery ?? []) as ToolRoleRuleRecord[]),
939+ [roleRulesQuery],
940+ );
704941
705942 const namespaces = useMemo(() => buildNamespaces(tools), [tools]);
943+ const sourceOptions = useMemo(() => {
944+ return [...new Set(tools
945+ .map((tool) => tool.source)
946+ .filter((source): source is string => typeof source === "string" && source.trim().length > 0))]
947+ .sort((a, b) => a.localeCompare(b));
948+ }, [tools]);
949+ const namespaceOptions = useMemo(() => {
950+ return [...new Set(namespaces.map((namespace) => namespace.prefix))]
951+ .sort((a, b) => a.localeCompare(b));
952+ }, [namespaces]);
953+
954+ const selectedRole = useMemo(
955+ () => roleItems.find((role) => role.id === activeRoleId) ?? null,
956+ [activeRoleId, roleItems],
957+ );
958+ const selectedRoleBindings = useMemo(
959+ () => bindingItems.filter((binding) => binding.roleId === activeRoleId),
960+ [activeRoleId, bindingItems],
961+ );
962+ const activeMemberOptions = useMemo(
963+ () => memberItems.filter((member) => member.status === "active"),
964+ [memberItems],
965+ );
706966
707967 // ── Handlers ──
708968
709969 const handleSave = async () => {
710970 if (!context) return;
711971
712− const pattern = form.resourcePattern.trim() || (form.selectedToolPaths.length > 0
713− ? derivePatternFromSelection(form.selectedToolPaths, namespaces)
714− : "*");
715− if (!pattern) {
716− toast.error("Tool path pattern is required");
717− return;
972+ let resourceType: PolicyResourceType = form.resourceType;
973+ let pattern = "";
974+ if (resourceType === "all_tools") {
975+ pattern = "*";
976+ } else if (resourceType === "source") {
977+ pattern = form.sourcePattern.trim();
978+ if (!pattern) {
979+ toast.error("Select a source for source-scoped policies");
980+ return;
981+ }
982+ } else if (resourceType === "namespace") {
983+ pattern = form.namespacePattern.trim();
984+ if (!pattern) {
985+ toast.error("Namespace pattern is required");
986+ return;
987+ }
988+ } else {
989+ pattern = form.resourcePattern.trim() || (form.selectedToolPaths.length > 0
990+ ? derivePatternFromSelection(form.selectedToolPaths, namespaces)
991+ : "*");
992+ if (!pattern) {
993+ toast.error("Tool path pattern is required");
994+ return;
995+ }
996+ resourceType = pattern === "*" ? "all_tools" : "tool_path";
718997 }
719998
720999 const priority = Number(form.priority.trim() || "100");
@@ -725,8 +1004,19 @@ export function PoliciesPanel({ · 4 unmodified lines
7251004
7261005 setSubmitting(true);
7271006 try {
1007+ const policyId = createToolPolicyId();
1008+ const roleId = toolPolicyRoleId(policyId);
1009+ const ruleId = toolPolicyRuleId(policyId);
1010+ const bindingId = toolPolicyBindingId(policyId);
7281011 const { effect, approvalMode } = getDecisionPayload(form.decision);
7291012 const argumentConditions = form.argumentConditions.filter((c) => c.key.trim().length > 0);
1013+ const selectorType = resourceType === "all_tools"
1014+ ? "all" as const
1015+ : resourceType === "source"
1016+ ? "source" as const
1017+ : resourceType === "namespace"
1018+ ? "namespace" as const
1019+ : "tool_path" as const;
7301020
7311021 // Map UI scope to backend scopeType + targetAccountId.
7321022 const scopeType = form.scope === "personal" ? "account" as const
@@ -734,20 +1024,53 @@ export function PoliciesPanel({ · 1 unmodified line
7341024 : "organization" as const;
7351025 const targetAccountId = form.scope === "personal" ? context.accountId : undefined;
7361026
737− await upsertAccessPolicy({
1027+ await upsertToolRole({
7381028 workspaceId: context.workspaceId,
7391029 sessionId: context.sessionId,
740− scopeType,
741− resourcePattern: pattern,
742− effect,
743− approvalMode,
744− matchType: pattern.includes("*") ? "glob" : "exact",
745− targetAccountId,
746− clientId: form.clientId.trim() || undefined,
747− argumentConditions: argumentConditions.length > 0 ? argumentConditions : undefined,
748− priority,
1030+ id: roleId,
1031+ name: `tool-policy:${policyId}`,
1032+ description: "Tool policy",
7491033 });
7501034
1035+ try {
1036+ await upsertToolRoleRule({
1037+ workspaceId: context.workspaceId,
1038+ sessionId: context.sessionId,
1039+ id: ruleId,
1040+ roleId,
1041+ selectorType,
1042+ sourceKey: resourceType === "source" ? pattern : undefined,
1043+ resourcePattern: resourceType === "source" || resourceType === "all_tools" ? undefined : pattern,
1044+ matchType: pattern.includes("*") ? "glob" : "exact",
1045+ effect,
1046+ approvalMode,
1047+ argumentConditions: argumentConditions.length > 0 ? argumentConditions : undefined,
1048+ priority,
1049+ });
1050+
1051+ await upsertToolRoleBinding({
1052+ workspaceId: context.workspaceId,
1053+ sessionId: context.sessionId,
1054+ id: bindingId,
1055+ roleId,
1056+ scopeType,
1057+ targetAccountId,
1058+ clientId: form.clientId.trim() || undefined,
1059+ status: "active",
1060+ });
1061+ } catch (error) {
1062+ try {
1063+ await deleteToolRole({
1064+ workspaceId: context.workspaceId,
1065+ sessionId: context.sessionId,
1066+ roleId,
1067+ });
1068+ } catch {
1069+ // Best effort cleanup.
1070+ }
1071+ throw error;
1072+ }
1073+
7511074 toast.success("Policy created");
7521075 setForm(defaultFormState());
7531076 setDialogOpen(false);
@@ -758,14 +1081,24 @@ export function PoliciesPanel({ · 4 unmodified lines
7581081 }
7591082 };
7601083
761− const handleDelete = useCallback(async (policyId: string) => {
1084+ const handleDelete = useCallback(async (policy: ToolPolicyRecord) => {
7621085 if (!context) return;
763− setDeletingId(policyId);
1086+ if (!isDirectToolPolicy(policy)) {
1087+ toast.error("This policy is managed by role bindings. Use role management to remove it.");
1088+ return;
1089+ }
1090+
1091+ if (!policy.roleId) {
1092+ toast.error("Cannot delete policy without role metadata");
1093+ return;
1094+ }
1095+
1096+ setDeletingId(policy.id);
7641097 try {
765− await deleteAccessPolicy({
1098+ await deleteToolRole({
7661099 workspaceId: context.workspaceId,
7671100 sessionId: context.sessionId,
768− policyId,
1101+ roleId: policy.roleId,
7691102 });
7701103 toast.success("Policy deleted");
7711104 } catch (error) {
@@ -773,12 +1106,172 @@ export function PoliciesPanel({ · 1 unmodified line
7731106 } finally {
7741107 setDeletingId(null);
7751108 }
776− }, [context, deleteAccessPolicy]);
1109+ }, [context, deleteToolRole]);
1110+
1111+ const handleCreateRole = useCallback(async () => {
1112+ if (!context) return;
1113+ const name = roleForm.name.trim();
1114+ if (!name) {
1115+ toast.error("Role name is required");
1116+ return;
1117+ }
1118+
1119+ setBusyRoleAction("create-role");
1120+ try {
1121+ const role = await upsertToolRole({
1122+ workspaceId: context.workspaceId,
1123+ sessionId: context.sessionId,
1124+ name,
1125+ description: roleForm.description.trim() || undefined,
1126+ });
1127+ setRoleForm(defaultRoleFormState());
1128+ setSelectedRoleId(role.id);
1129+ toast.success("Role created");
1130+ } catch (error) {
1131+ toast.error(error instanceof Error ? error.message : "Failed to create role");
1132+ } finally {
1133+ setBusyRoleAction(null);
1134+ }
1135+ }, [context, roleForm, upsertToolRole]);
1136+
1137+ const handleCreateRoleRule = useCallback(async () => {
1138+ if (!context || !activeRoleId) return;
1139+
1140+ const priority = Number(roleRuleForm.priority.trim() || "100");
1141+ if (!Number.isFinite(priority)) {
1142+ toast.error("Rule priority must be a number");
1143+ return;
1144+ }
1145+
1146+ const selectorType = roleRuleForm.selectorType;
1147+ const sourceKey = roleRuleForm.sourceKey.trim();
1148+ const resourcePattern = roleRuleForm.resourcePattern.trim();
1149+ if (selectorType === "source" && !sourceKey) {
1150+ toast.error("Source is required for source-scoped rules");
1151+ return;
1152+ }
1153+ if ((selectorType === "namespace" || selectorType === "tool_path") && !resourcePattern) {
1154+ toast.error("Pattern is required for this selector");
1155+ return;
1156+ }
1157+
1158+ setBusyRoleAction("create-rule");
1159+ try {
1160+ await upsertToolRoleRule({
1161+ workspaceId: context.workspaceId,
1162+ sessionId: context.sessionId,
1163+ roleId: activeRoleId,
1164+ selectorType,
1165+ sourceKey: selectorType === "source" ? sourceKey : undefined,
1166+ resourcePattern: selectorType === "source" || selectorType === "all" ? undefined : resourcePattern,
1167+ matchType: roleRuleForm.matchType,
1168+ effect: roleRuleForm.effect,
1169+ approvalMode: roleRuleForm.approvalMode,
1170+ priority,
1171+ });
1172+ setRoleRuleForm(defaultRoleRuleFormState());
1173+ toast.success("Rule added");
1174+ } catch (error) {
1175+ toast.error(error instanceof Error ? error.message : "Failed to add rule");
1176+ } finally {
1177+ setBusyRoleAction(null);
1178+ }
1179+ }, [activeRoleId, context, roleRuleForm, upsertToolRoleRule]);
1180+
1181+ const handleCreateRoleBinding = useCallback(async () => {
1182+ if (!context || !activeRoleId) return;
1183+
1184+ const scopeType = roleBindingForm.scopeType;
1185+ const targetAccountId = roleBindingForm.targetAccountId.trim();
1186+ if (scopeType === "account" && !targetAccountId) {
1187+ toast.error("Select an account for account-scoped bindings");
1188+ return;
1189+ }
1190+
1191+ setBusyRoleAction("create-binding");
1192+ try {
1193+ await upsertToolRoleBinding({
1194+ workspaceId: context.workspaceId,
1195+ sessionId: context.sessionId,
1196+ roleId: activeRoleId,
1197+ scopeType,
1198+ targetAccountId: scopeType === "account"
1199+ ? (targetAccountId as Id<"accounts">)
1200+ : undefined,
1201+ clientId: roleBindingForm.clientId.trim() || undefined,
1202+ status: roleBindingForm.status,
1203+ });
1204+ setRoleBindingForm(defaultRoleBindingFormState());
1205+ toast.success("Binding added");
1206+ } catch (error) {
1207+ toast.error(error instanceof Error ? error.message : "Failed to add binding");
1208+ } finally {
1209+ setBusyRoleAction(null);
1210+ }
1211+ }, [activeRoleId, context, roleBindingForm, upsertToolRoleBinding]);
1212+
1213+ const handleDeleteRole = useCallback(async (roleId: string) => {
1214+ if (!context) return;
1215+
1216+ setBusyRoleAction(`delete-role:${roleId}`);
1217+ try {
1218+ await deleteToolRole({
1219+ workspaceId: context.workspaceId,
1220+ sessionId: context.sessionId,
1221+ roleId,
1222+ });
1223+ if (activeRoleId === roleId) {
1224+ setSelectedRoleId(null);
1225+ }
1226+ toast.success("Role deleted");
1227+ } catch (error) {
1228+ toast.error(error instanceof Error ? error.message : "Failed to delete role");
1229+ } finally {
1230+ setBusyRoleAction(null);
1231+ }
1232+ }, [activeRoleId, context, deleteToolRole]);
1233+
1234+ const handleDeleteRule = useCallback(async (ruleId: string) => {
1235+ if (!context || !activeRoleId) return;
1236+
1237+ setBusyRoleAction(`delete-rule:${ruleId}`);
1238+ try {
1239+ await deleteToolRoleRule({
1240+ workspaceId: context.workspaceId,
1241+ sessionId: context.sessionId,
1242+ roleId: activeRoleId,
1243+ ruleId,
1244+ });
1245+ toast.success("Rule deleted");
1246+ } catch (error) {
1247+ toast.error(error instanceof Error ? error.message : "Failed to delete rule");
1248+ } finally {
1249+ setBusyRoleAction(null);
1250+ }
1251+ }, [activeRoleId, context, deleteToolRoleRule]);
1252+
1253+ const handleDeleteBinding = useCallback(async (bindingId: string) => {
1254+ if (!context) return;
1255+
1256+ setBusyRoleAction(`delete-binding:${bindingId}`);
1257+ try {
1258+ await deleteToolRoleBinding({
1259+ workspaceId: context.workspaceId,
1260+ sessionId: context.sessionId,
1261+ bindingId,
1262+ });
1263+ toast.success("Binding deleted");
1264+ } catch (error) {
1265+ toast.error(error instanceof Error ? error.message : "Failed to delete binding");
1266+ } finally {
1267+ setBusyRoleAction(null);
1268+ }
1269+ }, [context, deleteToolRoleBinding]);
7771270
7781271 // ── Group policies by decision for display ──
7791272
7801273 const groupedPolicies = useMemo(() => {
781− const groups: Record<PolicyDecisionType, AccessPolicyRecord[]> = {
1274+ const groups: Record<PolicyDecisionType, ToolPolicyRecord[]> = {
7821275 allow: [],
7831276 require_approval: [],
7841277 deny: [],
@@ -799,9 +1292,9 @@ export function PoliciesPanel({ · 14 unmodified lines
7991292 <div className="flex items-center gap-2.5">
8001293 <Shield className="h-4 w-4 text-muted-foreground" />
8011294 <div>
802− <h3 className="text-sm font-medium leading-none">Access Policies</h3>
1295+ <h3 className="text-sm font-medium leading-none">Tool Policies</h3>
8031296 <p className="text-[11px] text-muted-foreground mt-1">
804− Control which tools require approval, auto-approve, or are blocked
1297+ Control which tools or sources are approved, gated, or blocked
8051298 </p>
8061299 </div>
8071300 </div>
@@ -829,9 +1322,9 @@ export function PoliciesPanel({ · 21 unmodified lines
8291322 <div className="rounded-lg border border-dashed border-border/50 py-10 flex flex-col items-center gap-2.5">
8301323 <Shield className="h-8 w-8 text-muted-foreground/30" />
8311324 <div className="text-center">
832− <p className="text-xs text-muted-foreground">No policies configured</p>
1325+ <p className="text-xs text-muted-foreground">No tool policies configured</p>
8331326 <p className="text-[11px] text-muted-foreground/60 mt-0.5">
834− Tools use their default approval settings. Create a policy to customize behavior.
1327+ Tools use default approval behavior. Create a tool policy to customize it.
8351328 </p>
8361329 </div>
8371330 <Button
@@ -869,6 +1362,7 @@ export function PoliciesPanel({ · 31 unmodified lines
8691362 tools={tools}
8701363 currentAccountId={context?.accountId}
8711364 onDelete={handleDelete}
1365+ canDelete={isDirectToolPolicy(policy)}
8721366 deleting={deletingId === policy.id}
8731367 />
8741368 ))}
@@ -879,13 +1373,368 @@ export function PoliciesPanel({ · 4 unmodified lines
8791373 </div>
8801374 )}
8811375
1376+ <Separator className="bg-border/40" />
1377+
1378+ <div className="space-y-3">
1379+ <div className="flex items-center justify-between">
1380+ <div>
1381+ <h4 className="text-sm font-medium">Role Manager</h4>
1382+ <p className="text-[11px] text-muted-foreground mt-1">
1383+ Manage reusable tool roles, rule selectors, and scoped bindings.
1384+ </p>
1385+ </div>
1386+ </div>
1387+
1388+ {!canManageRoles ? (
1389+ <div className="rounded-lg border border-dashed border-border/50 p-4 text-xs text-muted-foreground">
1390+ Organization owner/admin role required to manage tool roles.
1391+ </div>
1392+ ) : (
1393+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-[280px_1fr]">
1394+ <div className="space-y-3 rounded-lg border border-border/60 bg-card p-3">
1395+ <Label className="text-xs text-muted-foreground">Create role</Label>
1396+ <Input
1397+ value={roleForm.name}
1398+ onChange={(event) => setRoleForm((state) => ({ ...state, name: event.target.value }))}
1399+ placeholder="Role name"
1400+ className="h-8 text-xs"
1401+ />
1402+ <Input
1403+ value={roleForm.description}
1404+ onChange={(event) => setRoleForm((state) => ({ ...state, description: event.target.value }))}
1405+ placeholder="Description (optional)"
1406+ className="h-8 text-xs"
1407+ />
1408+ <Button
1409+ size="sm"
1410+ className="h-8 text-xs"
1411+ onClick={handleCreateRole}
1412+ disabled={busyRoleAction === "create-role"}
1413+ >
1414+ {busyRoleAction === "create-role" ? "Creating..." : "Create role"}
1415+ </Button>
1416+
1417+ <Separator className="bg-border/40" />
1418+
1419+ <div className="space-y-1.5 max-h-[320px] overflow-y-auto">
1420+ {roleItems.length === 0 ? (
1421+ <p className="text-[11px] text-muted-foreground">No roles yet.</p>
1422+ ) : (
1423+ roleItems.map((role) => {
1424+ const bindingCount = bindingItems.filter((binding) => binding.roleId === role.id).length;
1425+ return (
1426+ <button
1427+ key={role.id}
1428+ type="button"
1429+ onClick={() => setSelectedRoleId(role.id)}
1430+ className={cn(
1431+ "w-full rounded-md border px-2 py-1.5 text-left",
1432+ selectedRoleId === role.id
1433+ ? "border-primary/40 bg-primary/5"
1434+ : "border-border/40 hover:bg-muted/30",
1435+ )}
1436+ >
1437+ <div className="flex items-center justify-between gap-2">
1438+ <span className="truncate text-xs font-medium">{role.name}</span>
1439+ <Badge variant="outline" className="text-[9px] px-1.5 py-0">
1440+ {bindingCount} bind
1441+ </Badge>
1442+ </div>
1443+ {role.description ? (
1444+ <p className="mt-1 truncate text-[10px] text-muted-foreground">{role.description}</p>
1445+ ) : null}
1446+ </button>
1447+ );
1448+ })
1449+ )}
1450+ </div>
1451+ </div>
1452+
1453+ <div className="space-y-4 rounded-lg border border-border/60 bg-card p-4">
1454+ {!selectedRole ? (
1455+ <p className="text-xs text-muted-foreground">Select a role to manage its rules and bindings.</p>
1456+ ) : (
1457+ <>
1458+ <div className="flex items-start justify-between gap-3">
1459+ <div>
1460+ <h5 className="text-sm font-medium">{selectedRole.name}</h5>
1461+ {selectedRole.description ? (
1462+ <p className="text-[11px] text-muted-foreground mt-1">{selectedRole.description}</p>
1463+ ) : null}
1464+ <p className="text-[10px] text-muted-foreground mt-1 font-mono">{selectedRole.id}</p>
1465+ </div>
1466+ <Button
1467+ variant="destructive"
1468+ size="sm"
1469+ className="h-8 text-xs"
1470+ onClick={() => handleDeleteRole(selectedRole.id)}
1471+ disabled={busyRoleAction === `delete-role:${selectedRole.id}`}
1472+ >
1473+ {busyRoleAction === `delete-role:${selectedRole.id}` ? "Deleting..." : "Delete role"}
1474+ </Button>
1475+ </div>
1476+
1477+ <Separator className="bg-border/40" />
1478+
1479+ <div className="space-y-2">
1480+ <div className="flex items-center justify-between">
1481+ <Label className="text-xs text-muted-foreground">Rules</Label>
1482+ <span className="text-[10px] text-muted-foreground">{selectedRoleRules.length}</span>
1483+ </div>
1484+ <div className="space-y-1.5 max-h-[200px] overflow-y-auto">
1485+ {selectedRoleRules.length === 0 ? (
1486+ <p className="text-[11px] text-muted-foreground">No rules.</p>
1487+ ) : (
1488+ selectedRoleRules.map((rule) => (
1489+ <div key={rule.id} className="rounded border border-border/40 px-2 py-1.5">
1490+ <div className="flex items-center justify-between gap-2">
1491+ <div className="flex items-center gap-1.5 min-w-0">
1492+ <Badge variant="outline" className="text-[9px] px-1.5 py-0 uppercase">
1493+ {rule.selectorType}
1494+ </Badge>
1495+ <span className="text-[10px] font-mono truncate">{roleRulePattern(rule) || "*"}</span>
1496+ </div>
1497+ <button
1498+ type="button"
1499+ onClick={() => handleDeleteRule(rule.id)}
1500+ disabled={busyRoleAction === `delete-rule:${rule.id}`}
1501+ className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
1502+ >
1503+ <Trash2 className="h-3 w-3" />
1504+ </button>
1505+ </div>
1506+ <div className="mt-1 flex items-center gap-1.5 text-[10px] text-muted-foreground">
1507+ <span>{rule.effect}</span>
1508+ <span>|</span>
1509+ <span>{rule.approvalMode}</span>
1510+ <span>|</span>
1511+ <span>p{rule.priority}</span>
1512+ </div>
1513+ </div>
1514+ ))
1515+ )}
1516+ </div>
1517+
1518+ <div className="grid grid-cols-1 gap-2 rounded-md border border-border/40 bg-muted/10 p-2 md:grid-cols-2">
1519+ <Select
1520+ value={roleRuleForm.selectorType}
1521+ onValueChange={(value) => setRoleRuleForm((state) => ({
1522+ ...state,
1523+ selectorType: value as ToolRoleRuleRecord["selectorType"],
1524+ }))}
1525+ >
1526+ <SelectTrigger className="h-8 text-xs">
1527+ <SelectValue />
1528+ </SelectTrigger>
1529+ <SelectContent>
1530+ <SelectItem value="all" className="text-xs">All tools</SelectItem>
1531+ <SelectItem value="source" className="text-xs">Source</SelectItem>
1532+ <SelectItem value="namespace" className="text-xs">Namespace</SelectItem>
1533+ <SelectItem value="tool_path" className="text-xs">Tool path</SelectItem>
1534+ </SelectContent>
1535+ </Select>
1536+
1537+ {roleRuleForm.selectorType === "source" ? (
1538+ <Select
1539+ value={roleRuleForm.sourceKey}
1540+ onValueChange={(value) => setRoleRuleForm((state) => ({ ...state, sourceKey: value }))}
1541+ >
1542+ <SelectTrigger className="h-8 text-xs font-mono">
1543+ <SelectValue placeholder="Source" />
1544+ </SelectTrigger>
1545+ <SelectContent>
1546+ {sourceOptions.map((source) => (
1547+ <SelectItem key={source} value={source} className="text-xs font-mono">
1548+ {source}
1549+ </SelectItem>
1550+ ))}
1551+ </SelectContent>
1552+ </Select>
1553+ ) : (
1554+ <Input
1555+ value={roleRuleForm.resourcePattern}
1556+ onChange={(event) => setRoleRuleForm((state) => ({ ...state, resourcePattern: event.target.value }))}
1557+ placeholder={roleRuleForm.selectorType === "namespace" ? "github.repos.*" : "github.repos.delete"}
1558+ className="h-8 text-xs font-mono"
1559+ disabled={roleRuleForm.selectorType === "all"}
1560+ />
1561+ )}
1562+
1563+ <Select
1564+ value={roleRuleForm.effect}
1565+ onValueChange={(value) => setRoleRuleForm((state) => ({
1566+ ...state,
1567+ effect: value as ToolRoleRuleRecord["effect"],
1568+ }))}
1569+ >
1570+ <SelectTrigger className="h-8 text-xs">
1571+ <SelectValue />
1572+ </SelectTrigger>
1573+ <SelectContent>
1574+ <SelectItem value="allow" className="text-xs">Allow</SelectItem>
1575+ <SelectItem value="deny" className="text-xs">Deny</SelectItem>
1576+ </SelectContent>
1577+ </Select>
1578+
1579+ <Select
1580+ value={roleRuleForm.approvalMode}
1581+ onValueChange={(value) => setRoleRuleForm((state) => ({
1582+ ...state,
1583+ approvalMode: value as ToolRoleRuleRecord["approvalMode"],
1584+ }))}
1585+ >
1586+ <SelectTrigger className="h-8 text-xs">
1587+ <SelectValue />
1588+ </SelectTrigger>
1589+ <SelectContent>
1590+ <SelectItem value="inherit" className="text-xs">Inherit</SelectItem>
1591+ <SelectItem value="required" className="text-xs">Require approval</SelectItem>
1592+ <SelectItem value="auto" className="text-xs">Auto approve</SelectItem>
1593+ </SelectContent>
1594+ </Select>
1595+
1596+ <Input
1597+ value={roleRuleForm.priority}
1598+ onChange={(event) => setRoleRuleForm((state) => ({ ...state, priority: event.target.value }))}
1599+ placeholder="priority"
1600+ className="h-8 text-xs font-mono"
1601+ />
1602+
1603+ <Button
1604+ size="sm"
1605+ className="h-8 text-xs md:col-span-2"
1606+ onClick={handleCreateRoleRule}
1607+ disabled={busyRoleAction === "create-rule"}
1608+ >
1609+ {busyRoleAction === "create-rule" ? "Adding rule..." : "Add rule"}
1610+ </Button>
1611+ </div>
1612+ </div>
1613+
1614+ <Separator className="bg-border/40" />
1615+
1616+ <div className="space-y-2">
1617+ <div className="flex items-center justify-between">
1618+ <Label className="text-xs text-muted-foreground">Bindings</Label>
1619+ <span className="text-[10px] text-muted-foreground">{selectedRoleBindings.length}</span>
1620+ </div>
1621+ <div className="space-y-1.5 max-h-[200px] overflow-y-auto">
1622+ {selectedRoleBindings.length === 0 ? (
1623+ <p className="text-[11px] text-muted-foreground">No bindings.</p>
1624+ ) : (
1625+ selectedRoleBindings.map((binding) => (
1626+ <div key={binding.id} className="rounded border border-border/40 px-2 py-1.5">
1627+ <div className="flex items-center justify-between gap-2">
1628+ <div className="flex items-center gap-1.5">
1629+ <Badge variant="outline" className="text-[9px] px-1.5 py-0 uppercase">
1630+ {binding.scopeType}
1631+ </Badge>
1632+ <Badge variant="outline" className="text-[9px] px-1.5 py-0 uppercase">
1633+ {binding.status}
1634+ </Badge>
1635+ </div>
1636+ <button
1637+ type="button"
1638+ onClick={() => handleDeleteBinding(binding.id)}
1639+ disabled={busyRoleAction === `delete-binding:${binding.id}`}
1640+ className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
1641+ >
1642+ <Trash2 className="h-3 w-3" />
1643+ </button>
1644+ </div>
1645+ {binding.targetAccountId || binding.clientId ? (
1646+ <div className="mt-1 text-[10px] text-muted-foreground font-mono truncate">
1647+ {[binding.targetAccountId, binding.clientId].filter(Boolean).join(" | ")}
1648+ </div>
1649+ ) : null}
1650+ </div>
1651+ ))
1652+ )}
1653+ </div>
1654+
1655+ <div className="grid grid-cols-1 gap-2 rounded-md border border-border/40 bg-muted/10 p-2 md:grid-cols-2">
1656+ <Select
1657+ value={roleBindingForm.scopeType}
1658+ onValueChange={(value) => setRoleBindingForm((state) => ({
1659+ ...state,
1660+ scopeType: value as ToolRoleBindingRecord["scopeType"],
1661+ }))}
1662+ >
1663+ <SelectTrigger className="h-8 text-xs">
1664+ <SelectValue />
1665+ </SelectTrigger>
1666+ <SelectContent>
1667+ <SelectItem value="workspace" className="text-xs">Workspace</SelectItem>
1668+ <SelectItem value="organization" className="text-xs">Organization</SelectItem>
1669+ <SelectItem value="account" className="text-xs">Account</SelectItem>
1670+ </SelectContent>
1671+ </Select>
1672+
1673+ {roleBindingForm.scopeType === "account" ? (
1674+ <Select
1675+ value={roleBindingForm.targetAccountId}
1676+ onValueChange={(value) => setRoleBindingForm((state) => ({ ...state, targetAccountId: value }))}
1677+ >
1678+ <SelectTrigger className="h-8 text-xs">
1679+ <SelectValue placeholder="Select account" />
1680+ </SelectTrigger>
1681+ <SelectContent>
1682+ {activeMemberOptions.map((member) => (
1683+ <SelectItem key={member.accountId} value={member.accountId} className="text-xs">
1684+ {member.displayName}{member.email ? ` (${member.email})` : ""}
1685+ </SelectItem>
1686+ ))}
1687+ </SelectContent>
1688+ </Select>
1689+ ) : (
1690+ <Input
1691+ value={roleBindingForm.clientId}
1692+ onChange={(event) => setRoleBindingForm((state) => ({ ...state, clientId: event.target.value }))}
1693+ placeholder="client id (optional)"
1694+ className="h-8 text-xs font-mono"
1695+ />
1696+ )}
1697+
1698+ <Select
1699+ value={roleBindingForm.status}
1700+ onValueChange={(value) => setRoleBindingForm((state) => ({
1701+ ...state,
1702+ status: value as ToolRoleBindingRecord["status"],
1703+ }))}
1704+ >
1705+ <SelectTrigger className="h-8 text-xs">
1706+ <SelectValue />
1707+ </SelectTrigger>
1708+ <SelectContent>
1709+ <SelectItem value="active" className="text-xs">Active</SelectItem>
1710+ <SelectItem value="disabled" className="text-xs">Disabled</SelectItem>
1711+ </SelectContent>
1712+ </Select>
1713+
1714+ <Button
1715+ size="sm"
1716+ className="h-8 text-xs"
1717+ onClick={handleCreateRoleBinding}
1718+ disabled={busyRoleAction === "create-binding"}
1719+ >
1720+ {busyRoleAction === "create-binding" ? "Adding binding..." : "Add binding"}
1721+ </Button>
1722+ </div>
1723+ </div>
1724+ </>
1725+ )}
1726+ </div>
1727+ </div>
1728+ )}
1729+ </div>
1730+
8821731 {/* Create Policy Dialog */}
8831732 <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
8841733 <DialogContent className="sm:max-w-[540px]">
8851734 <DialogHeader>
886− <DialogTitle className="text-base">New Access Policy</DialogTitle>
1735+ <DialogTitle className="text-base">New Tool Policy</DialogTitle>
8871736 <DialogDescription className="text-xs">
888− Define which tools are auto-approved, require manual approval, or blocked.
1737+ Define which tools, namespaces, or sources are auto-approved, gated, or blocked.
8891738 </DialogDescription>
8901739 </DialogHeader>
8911740
@@ -919,38 +1768,130 @@ export function PoliciesPanel({ · 27 unmodified lines
9191768 </div>
9201769 </div>
9211770
922− {/* Tools selection */}
1771+ {/* Resource target */}
9231772 <div className="space-y-2">
924− <Label className="text-xs text-muted-foreground">Apply to tools</Label>
925− {tools.length > 0 ? (
926− <ToolPicker
927− tools={tools}
928− selectedPaths={form.selectedToolPaths}
929− onSelectionChange={(paths) => setForm((s) => ({ ...s, selectedToolPaths: paths }))}
930− onPatternChange={(pattern) => setForm((s) => ({ ...s, resourcePattern: pattern }))}
931− />
932− ) : (
933− <Input
934− value={form.resourcePattern}
935− onChange={(e) => setForm((s) => ({ ...s, resourcePattern: e.target.value }))}
936− placeholder="github.repos.* or * for all tools"
937− className="h-9 text-xs font-mono bg-background"
938− />
1773+ <Label className="text-xs text-muted-foreground">Apply to</Label>
1774+ <Select
1775+ value={form.resourceType}
1776+ onValueChange={(value) => {
1777+ const nextType = value as PolicyResourceType;
1778+ setForm((state) => ({
1779+ ...state,
1780+ resourceType: nextType,
1781+ resourcePattern: nextType === "all_tools" ? "*" : state.resourcePattern,
1782+ selectedToolPaths: nextType === "tool_path" ? state.selectedToolPaths : [],
1783+ }));
1784+ }}
1785+ >
1786+ <SelectTrigger className="h-9 text-xs bg-background">
1787+ <SelectValue />
1788+ </SelectTrigger>
1789+ <SelectContent>
1790+ {(Object.keys(RESOURCE_TYPE_CONFIG) as PolicyResourceType[]).map((resourceType) => (
1791+ <SelectItem key={resourceType} value={resourceType} className="text-xs">
1792+ {RESOURCE_TYPE_CONFIG[resourceType].label}
1793+ </SelectItem>
1794+ ))}
1795+ </SelectContent>
1796+ </Select>
1797+ <p className="text-[10px] text-muted-foreground/70 leading-tight">
1798+ {RESOURCE_TYPE_CONFIG[form.resourceType].description}
1799+ </p>
1800+
1801+ {form.resourceType === "all_tools" && (
1802+ <div className="h-9 rounded-md border border-border/40 bg-muted/20 px-3 flex items-center text-[11px] font-mono text-muted-foreground">
1803+ *
1804+ </div>
1805+ )}
1806+
1807+ {form.resourceType === "source" && (
1808+ <>
1809+ {sourceOptions.length > 0 ? (
1810+ <Select
1811+ value={form.sourcePattern}
1812+ onValueChange={(sourcePattern) => setForm((state) => ({ ...state, sourcePattern }))}
1813+ >
1814+ <SelectTrigger className="h-9 text-xs font-mono bg-background">
1815+ <SelectValue placeholder="Select source" />
1816+ </SelectTrigger>
1817+ <SelectContent>
1818+ {sourceOptions.map((source) => (
1819+ <SelectItem key={source} value={source} className="text-xs font-mono">
1820+ {source}
1821+ </SelectItem>
1822+ ))}
1823+ </SelectContent>
1824+ </Select>
1825+ ) : (
1826+ <Input
1827+ value={form.sourcePattern}
1828+ onChange={(event) => setForm((state) => ({ ...state, sourcePattern: event.target.value }))}
1829+ placeholder="source:github"
1830+ className="h-9 text-xs font-mono bg-background"
1831+ />
1832+ )}
1833+ </>
9391834 )}
940− {/* Editable pattern override */}
941− {form.selectedToolPaths.length > 0 && (
942− <div className="flex items-center gap-2">
943− <span className="text-[10px] text-muted-foreground shrink-0">Pattern:</span>
1835+
1836+ {form.resourceType === "namespace" && (
1837+ <>
1838+ {namespaceOptions.length > 0 ? (
1839+ <Select
1840+ value={form.namespacePattern}
1841+ onValueChange={(namespace) => setForm((state) => ({ ...state, namespacePattern: `${namespace}.*` }))}
1842+ >
1843+ <SelectTrigger className="h-9 text-xs font-mono bg-background">
1844+ <SelectValue placeholder="Select namespace" />
1845+ </SelectTrigger>
1846+ <SelectContent>
1847+ {namespaceOptions.map((namespace) => (
1848+ <SelectItem key={namespace} value={namespace} className="text-xs font-mono">
1849+ {namespace}.*
1850+ </SelectItem>
1851+ ))}
1852+ </SelectContent>
1853+ </Select>
1854+ ) : null}
9441855 <Input
945− value={form.resourcePattern || derivePatternFromSelection(form.selectedToolPaths, namespaces)}
946− onChange={(e) => setForm((s) => ({ ...s, resourcePattern: e.target.value }))}
947− className="h-6 text-[10px] font-mono bg-muted/30 border-border/30"
1856+ value={form.namespacePattern}
1857+ onChange={(event) => setForm((state) => ({ ...state, namespacePattern: event.target.value }))}
1858+ placeholder="github.repos.*"
1859+ className="h-9 text-xs font-mono bg-background"
9481860 />
949− </div>
1861+ </>
1862+ )}
1863+
1864+ {form.resourceType === "tool_path" && (
1865+ <>
1866+ {tools.length > 0 ? (
1867+ <ToolPicker
1868+ tools={tools}
1869+ selectedPaths={form.selectedToolPaths}
1870+ onSelectionChange={(paths) => setForm((state) => ({ ...state, selectedToolPaths: paths }))}
1871+ onPatternChange={(pattern) => setForm((state) => ({ ...state, resourcePattern: pattern }))}
1872+ />
1873+ ) : (
1874+ <Input
1875+ value={form.resourcePattern}
1876+ onChange={(event) => setForm((state) => ({ ...state, resourcePattern: event.target.value }))}
1877+ placeholder="github.repos.* or github.repos.list"
1878+ className="h-9 text-xs font-mono bg-background"
1879+ />
1880+ )}
1881+ {form.selectedToolPaths.length > 0 && (
1882+ <div className="flex items-center gap-2">
1883+ <span className="text-[10px] text-muted-foreground shrink-0">Pattern:</span>
1884+ <Input
1885+ value={form.resourcePattern || derivePatternFromSelection(form.selectedToolPaths, namespaces)}
1886+ onChange={(event) => setForm((state) => ({ ...state, resourcePattern: event.target.value }))}
1887+ className="h-6 text-[10px] font-mono bg-muted/30 border-border/30"
1888+ />
1889+ </div>
1890+ )}
1891+ </>
9501892 )}
9511893 </div>
9521894
953− {/* Scope */}
9541895 {/* Scope */}
9551896 <div className="space-y-2">
9561897 <Label className="text-xs text-muted-foreground">Scope</Label>
@@ -982,6 +1923,27 @@ export function PoliciesPanel({ · 25 unmodified lines
9821923 className="h-9 text-xs font-mono bg-background w-24"
9831924 />
9841925 </div>
1926+
1927+ {/* Advanced filters */}
1928+ <div className="space-y-3 rounded-md border border-border/40 bg-muted/10 p-3">
1929+ <div className="space-y-1">
1930+ <Label className="text-xs text-muted-foreground">Client ID</Label>
1931+ <Input
1932+ value={form.clientId}
1933+ onChange={(event) => setForm((state) => ({ ...state, clientId: event.target.value }))}
1934+ placeholder="optional client identifier"
1935+ className="h-8 text-xs font-mono bg-background"
1936+ />
1937+ <p className="text-[10px] text-muted-foreground/60 leading-tight">
1938+ When set, this policy only applies to tool calls from this client ID.
1939+ </p>
1940+ </div>
1941+
1942+ <ArgumentConditionsEditor
1943+ conditions={form.argumentConditions}
1944+ onChange={(argumentConditions) => setForm((state) => ({ ...state, argumentConditions }))}
1945+ />
1946+ </div>
9851947 </div>
9861948
9871949 <DialogFooter>
executor/apps/web/src/hooks/use/workspace-tools.ts+0−5
@@ -47,14 +47,12 @@ type ToolDetailDescriptor = Pick<ToolDescriptor, "path" | "description" | "displ · 46 unmodified lines
4747 type GetToolDetailsMutation = (args: {
4848 workspaceId: Id<"workspaces">;
4949 sessionId?: string;
50− clientId?: string;
5150 toolPaths: string[];
5251 }) => Promise<Record<string, ToolDetailDescriptor>>;
5352
5453 type ListToolsWithWarningsAction = (args: {
5554 workspaceId: Id<"workspaces">;
5655 accountId?: string;
57− clientId?: string;
5856 sessionId?: string;
5957 includeDetails?: boolean;
6058 includeSourceMeta?: boolean;
@@ -122,7 +120,6 @@ export function useWorkspaceTools( · 61 unmodified lines
122120 return await listToolsWithWarnings({
123121 workspaceId: context.workspaceId,
124122 ...(context.accountId && { accountId: context.accountId }),
125− ...(context.clientId && { clientId: context.clientId }),
126123 ...(context.sessionId && { sessionId: context.sessionId }),
127124 includeDetails,
128125 fetchAll: true,
@@ -152,7 +149,6 @@ export function useWorkspaceTools( · 23 unmodified lines
152149 const detailsByPath = await listToolDetails({
153150 workspaceId: context.workspaceId,
154151 sessionId: context.sessionId,
155− ...(context.clientId && { clientId: context.clientId }),
156152 toolPaths: missing,
157153 });
158154
@@ -206,7 +202,6 @@ export function useWorkspaceTools( · 47 unmodified lines
206202 await listToolsWithWarnings({
207203 workspaceId: context.workspaceId,
208204 ...(context.accountId && { accountId: context.accountId }),
209− ...(context.clientId && { clientId: context.clientId }),
210205 ...(context.sessionId && { sessionId: context.sessionId }),
211206 includeDetails: false,
212207 includeSourceMeta: false,
executor/apps/web/src/lib/runtime-config.ts+16−0
@@ -4,6 +4,7 @@ export type ExecutorRuntimeConfig = { · 3 unmodified lines
44 workosClientId?: string;
55 stripePriceId?: string;
66 executorHttpUrl?: string;
7+ allowLocalVm?: boolean;
78 };
89
910 declare global {
@@ -17,6 +18,16 @@ function trim(value: string | null | undefined): string | undefined { · 7 unmodified lines
1718 return candidate && candidate.length > 0 ? candidate : undefined;
1819 }
1920
21+const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
22+
23+function parseBooleanEnv(value: string | null | undefined): boolean | undefined {
24+ const candidate = trim(value);
25+ if (!candidate) {
26+ return undefined;
27+ }
28+ return TRUTHY_ENV_VALUES.has(candidate.toLowerCase());
29+}
30+
2031 function normalizeDeploymentSlug(value: string | undefined): string | undefined {
2132 const candidate = trim(value);
2233 if (!candidate) {
@@ -53,6 +64,7 @@ export function readRuntimeConfig(): ExecutorRuntimeConfig { · 30 unmodified lines
5364 workosClientId: trim(import.meta.env.VITE_WORKOS_CLIENT_ID),
5465 stripePriceId: trim(import.meta.env.VITE_STRIPE_PRICE_ID),
5566 executorHttpUrl: trim(import.meta.env.VITE_EXECUTOR_HTTP_URL),
67+ allowLocalVm: parseBooleanEnv(import.meta.env.VITE_DANGEROUSLY_ALLOW_LOCAL_VM),
5668 };
5769 }
5870
@@ -94,5 +106,9 @@ export function runtimeConfigFromEnv(): ExecutorRuntimeConfig { · 35 unmodified lines
94106 ?? process.env.EXECUTOR_HTTP_URL
95107 ?? process.env.VITE_EXECUTOR_HTTP_URL,
96108 ),
109+ allowLocalVm: parseBooleanEnv(
110+ process.env.DANGEROUSLY_ALLOW_LOCAL_VM
111+ ?? process.env.VITE_DANGEROUSLY_ALLOW_LOCAL_VM,
112+ ),
97113 };
98114 }
executor/apps/web/src/lib/runtime-targets.ts+23−7
@@ -1,4 +1,5 @@
11 import type { RuntimeTargetDescriptor } from "@/lib/types";
2+import { readRuntimeConfig } from "@/lib/runtime-config";
23
34 const LOCAL_BUN_RUNTIME_ID = "local-bun";
45 const CLOUDFLARE_WORKER_LOADER_RUNTIME_ID = "cloudflare-worker-loader";
@@ -27,16 +28,26 @@ function isTruthyEnvValue(value: string | undefined): boolean { · 22 unmodified lines
2728 return TRUTHY_ENV_VALUES.has(value.trim().toLowerCase());
2829 }
2930
30−function isRuntimeEnabled(runtimeId: string): boolean {
31+function resolveLocalVmAllowed(override?: boolean): boolean {
32+ if (typeof override === "boolean") {
33+ return override;
34+ }
35+
36+ const fromRuntimeConfig = readRuntimeConfig().allowLocalVm;
37+ if (typeof fromRuntimeConfig === "boolean") {
38+ return fromRuntimeConfig;
39+ }
40+
41+ return typeof process !== "undefined"
42+ ? isTruthyEnvValue(process.env[DANGEROUSLY_ALLOW_LOCAL_VM_ENV_KEY])
43+ : false;
44+}
45+
46+function isRuntimeEnabled(runtimeId: string, localVmAllowed: boolean): boolean {
3147 if (runtimeId !== LOCAL_BUN_RUNTIME_ID && runtimeId !== CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) {
3248 return false;
3349 }
3450
35− const localVmAllowed =
36− typeof process !== "undefined"
37− ? isTruthyEnvValue(process.env[DANGEROUSLY_ALLOW_LOCAL_VM_ENV_KEY])
38− : false;
39−
4051 if (localVmAllowed) {
4152 return true;
4253 }
@@ -45,5 +56,10 @@ function isRuntimeEnabled(runtimeId: string): boolean { · 2 unmodified lines
4556 }
4657
4758 export function listRuntimeTargets(): RuntimeTargetDescriptor[] {
48− return RUNTIME_TARGETS.filter((target) => isRuntimeEnabled(target.id));
59+ return listRuntimeTargetsWithOptions({});
60+}
61+
62+export function listRuntimeTargetsWithOptions(options: { allowLocalVm?: boolean }): RuntimeTargetDescriptor[] {
63+ const localVmAllowed = resolveLocalVmAllowed(options.allowLocalVm);
64+ return RUNTIME_TARGETS.filter((target) => isRuntimeEnabled(target.id, localVmAllowed));
4965 }
executor/apps/web/src/lib/session-context.tsx+5−0
@@ -49,6 +49,11 @@ interface SessionState { · 48 unmodified lines
4949 clientConfig: {
5050 authProviderMode: string;
5151 invitesProvider: string;
52+ runtime?: {
53+ allowLocalVm: boolean;
54+ defaultRuntimeId: string;
55+ enabledRuntimeIds: string[];
56+ };
5257 features: {
5358 organizations: boolean;
5459 billing: boolean;
executor/apps/web/src/lib/types.ts+12−17
@@ -1,4 +1,10 @@
11 import type { Id } from "@executor/database/convex/_generated/dataModel";
2+import type {
3+ ToolPolicyRecord as CoreToolPolicyRecord,
4+ ToolRoleBindingRecord as CoreToolRoleBindingRecord,
5+ ToolRoleRecord as CoreToolRoleRecord,
6+ ToolRoleRuleRecord as CoreToolRoleRuleRecord,
7+} from "@executor/core/types";
28
39 // ── Shared types (inlined from @executor/contracts) ──────────────────────────
410
@@ -9,6 +15,8 @@ export type PolicyScopeType = "account" | "organization" | "workspace"; · 4 unmodified lines
915 export type PolicyMatchType = "glob" | "exact";
1016 export type PolicyEffect = "allow" | "deny";
1117 export type PolicyApprovalMode = "inherit" | "auto" | "required";
18+export type ToolRoleSelectorType = "all" | "source" | "namespace" | "tool_path";
19+export type ToolRoleBindingStatus = "active" | "disabled";
1220 export type ArgumentConditionOperator = "equals" | "contains" | "starts_with" | "not_equals";
1321
1422 export interface ArgumentCondition {
@@ -76,23 +84,10 @@ export interface TaskEventRecord { · 61 unmodified lines
7684 createdAt: number;
7785 }
7886
79−export interface AccessPolicyRecord {
80− id: string;
81− scopeType: PolicyScopeType;
82− organizationId?: string;
83− workspaceId?: string;
84− targetAccountId?: string;
85− clientId?: string;
86− resourceType: "tool_path";
87− resourcePattern: string;
88− matchType: PolicyMatchType;
89− effect: PolicyEffect;
90− approvalMode: PolicyApprovalMode;
91− argumentConditions?: ArgumentCondition[];
92− priority: number;
93− createdAt: number;
94− updatedAt: number;
95−}
87+export type ToolPolicyRecord = CoreToolPolicyRecord;
88+export type ToolRoleRecord = CoreToolRoleRecord;
89+export type ToolRoleRuleRecord = CoreToolRoleRuleRecord;
90+export type ToolRoleBindingRecord = CoreToolRoleBindingRecord;
9691
9792 export interface CredentialRecord {
9893 id: string;
executor/packages/core/src/function-builders.ts+15−0
@@ -146,6 +146,11 @@ export const organizationQuery = convexCustomQuery(query, { · 145 unmodified lines
146146 throw new Error("You are not a member of this organization");
147147 }
148148
149+ const organization = await ctx.db.get(args.organizationId);
150+ if (!organization || organization.status !== "active") {
151+ throw new Error("Organization is inactive");
152+ }
153+
149154 ensureOrganizationRole(accountMembership.role, options);
150155
151156 return {
@@ -172,6 +177,11 @@ export const organizationMutation = convexCustomMutation(mutation, { · 20 unmodified lines
172177 throw new Error("You are not a member of this organization");
173178 }
174179
180+ const organization = await ctx.db.get(args.organizationId);
181+ if (!organization || organization.status !== "active") {
182+ throw new Error("Organization is inactive");
183+ }
184+
175185 ensureOrganizationRole(accountMembership.role, options);
176186
177187 return {
@@ -238,6 +248,11 @@ export const internalOrganizationQuery = convexCustomQuery(internalQuery, { · 60 unmodified lines
238248 throw new Error("You are not a member of this organization");
239249 }
240250
251+ const organization = await ctx.db.get(args.organizationId);
252+ if (!organization || organization.status !== "active") {
253+ throw new Error("Organization is inactive");
254+ }
255+
241256 ensureOrganizationRole(accountMembership.role, options);
242257
243258 return {
executor/packages/core/src/mcp-server.ts+1−3
@@ -38,7 +38,6 @@ function createRunCodeTool( · 37 unmodified lines
3838 timeoutMs?: number;
3939 runtimeId?: string;
4040 metadata?: Record<string, unknown>;
41− clientId?: string;
4241 sessionId?: string;
4342 waitForResult?: boolean;
4443 resultTimeoutMs?: number;
@@ -62,7 +61,7 @@ function createRunCodeTool( · 17 unmodified lines
6261 context = {
6362 workspaceId: anonymous.workspaceId,
6463 accountId: anonymous.accountId,
65− clientId: input.clientId ?? anonymous.clientId,
64+ clientId: anonymous.clientId,
6665 sessionId: anonymous.sessionId,
6766 };
6867 }
@@ -178,7 +177,6 @@ const FULL_INPUT = z.object({ · 109 unmodified lines
178177 timeoutMs: z.number().int().min(1).max(600_000).optional(),
179178 runtimeId: z.string().optional(),
180179 metadata: z.record(z.unknown()).optional(),
181− clientId: z.string().optional(),
182180 sessionId: z.string().optional(),
183181 waitForResult: z.boolean().optional(),
184182 resultTimeoutMs: z.number().int().min(100).max(900_000).optional(),
executor/packages/core/src/runtimes/cloudflare/worker/loader-runtime.ts+10−1
@@ -125,11 +125,20 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 124 unmodified lines
125125 return mkError("Cloudflare sandbox execution response missing status");
126126 }
127127
128+ const normalizedError = typeof body.value.error === "string"
129+ ? body.value.error.trim()
130+ : "";
131+ const error = normalizedError.length > 0
132+ ? normalizedError
133+ : body.value.status === "failed"
134+ ? "Cloudflare sandbox execution failed without an error message"
135+ : undefined;
136+
128137 return {
129138 ok: true,
130139 status: body.value.status,
131140 result: body.value.result,
132− error: body.value.error,
141+ error,
133142 exitCode: body.value.exitCode,
134143 durationMs: Date.now() - startedAt,
135144 };
executor/packages/core/src/types.ts+1−1
@@ -100,7 +100,7 @@ export interface ArgumentCondition { · 99 unmodified lines
100100 value: string;
101101 }
102102
103−export interface AccessPolicyRecord {
103+export interface ToolPolicyRecord {
104104 id: string;
105105 scopeType: PolicyScopeType;
106106 organizationId: Id<"organizations">;
executor/packages/database/convex/_generated/api.d.ts+13−106
@@ -104,7 +104,6 @@ export declare const api: { · 103 unmodified lines
104104 "public",
105105 {
106106 accountId?: Id<"accounts">;
107− clientId?: string;
108107 code: string;
109108 metadata?: Record<string, any>;
110109 runtimeId?: string;
@@ -136,7 +135,6 @@ export declare const api: { · 25 unmodified lines
136135 {
137136 accountId?: Id<"accounts">;
138137 buildId?: string;
139− clientId?: string;
140138 cursor?: string;
141139 fetchAll?: boolean;
142140 includeDetails?: boolean;
@@ -156,7 +154,6 @@ export declare const api: { · 13 unmodified lines
156154 "public",
157155 {
158156 accountId?: Id<"accounts">;
159− clientId?: string;
160157 config: Record<string, any>;
161158 name: string;
162159 sessionId?: string;
@@ -176,7 +173,6 @@ export declare const api: { · 13 unmodified lines
176173 organizationId: Id<"organizations">;
177174 role: "owner" | "admin" | "member" | "billing_admin";
178175 sessionId?: string;
179− workspaceId?: Id<"workspaces">;
180176 },
181177 any
182178 >;
@@ -307,13 +303,7 @@ export declare const api: { · 124 unmodified lines
307303 bootstrapAnonymousSession: FunctionReference<
308304 "mutation",
309305 "public",
310− { accountId?: string; clientId?: string; sessionId?: string },
311− any
312− >;
313− deleteAccessPolicy: FunctionReference<
314− "mutation",
315− "public",
316− { policyId: string; sessionId?: string; workspaceId: Id<"workspaces"> },
306+ { accountId?: string; sessionId?: string },
317307 any
318308 >;
319309 deleteToolRole: FunctionReference<
@@ -355,7 +345,6 @@ export declare const api: { · 35 unmodified lines
355345 "mutation",
356346 "public",
357347 {
358− clientId?: string;
359348 sessionId?: string;
360349 toolPaths: Array<string>;
361350 workspaceId: Id<"workspaces">;
@@ -368,25 +357,25 @@ export declare const api: { · 6 unmodified lines
368357 { sessionId?: string; workspaceId: Id<"workspaces"> },
369358 any
370359 >;
371− listAccessPolicies: FunctionReference<
360+ listCredentials: FunctionReference<
372361 "query",
373362 "public",
374363 { sessionId?: string; workspaceId: Id<"workspaces"> },
375364 any
376365 >;
377− listCredentials: FunctionReference<
366+ listPendingApprovals: FunctionReference<
378367 "query",
379368 "public",
380369 { sessionId?: string; workspaceId: Id<"workspaces"> },
381370 any
382371 >;
383− listPendingApprovals: FunctionReference<
372+ listTasks: FunctionReference<
384373 "query",
385374 "public",
386375 { sessionId?: string; workspaceId: Id<"workspaces"> },
387376 any
388377 >;
389− listTasks: FunctionReference<
378+ listToolPolicies: FunctionReference<
390379 "query",
391380 "public",
392381 { sessionId?: string; workspaceId: Id<"workspaces"> },
@@ -434,30 +423,6 @@ export declare const api: { · 41 unmodified lines
434423 },
435424 any
436425 >;
437− upsertAccessPolicy: FunctionReference<
438− "mutation",
439− "public",
440− {
441− approvalMode?: "inherit" | "auto" | "required";
442− argumentConditions?: Array<{
443− key: string;
444− operator: "equals" | "contains" | "starts_with" | "not_equals";
445− value: string;
446− }>;
447− clientId?: string;
448− effect?: "allow" | "deny";
449− id?: string;
450− matchType?: "glob" | "exact";
451− priority?: number;
452− resourcePattern: string;
453− resourceType?: "all_tools" | "source" | "namespace" | "tool_path";
454− scopeType?: "account" | "organization" | "workspace";
455− sessionId?: string;
456− targetAccountId?: Id<"accounts">;
457− workspaceId: Id<"workspaces">;
458− },
459− any
460− >;
461426 upsertCredential: FunctionReference<
462427 "mutation",
463428 "public",
@@ -777,12 +742,6 @@ export declare const internal: { · 313 unmodified lines
777742 any
778743 >;
779744 };
780− deleteAccessPolicy: FunctionReference<
781− "mutation",
782− "internal",
783− { policyId: string; workspaceId: Id<"workspaces"> },
784− any
785− >;
786745 deleteToolRole: FunctionReference<
787746 "mutation",
788747 "internal",
@@ -843,12 +802,6 @@ export declare const internal: { · 54 unmodified lines
843802 { callId: string; taskId: string },
844803 any
845804 >;
846− listAccessPolicies: FunctionReference<
847− "query",
848− "internal",
849− { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> },
850− any
851− >;
852805 listApprovals: FunctionReference<
853806 "query",
854807 "internal",
@@ -896,6 +849,12 @@ export declare const internal: { · 41 unmodified lines
896849 { taskId: string },
897850 any
898851 >;
852+ listToolPolicies: FunctionReference<
853+ "query",
854+ "internal",
855+ { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> },
856+ any
857+ >;
899858 listToolRoleBindings: FunctionReference<
900859 "query",
901860 "internal",
@@ -938,12 +897,6 @@ export declare const internal: { · 36 unmodified lines
938897 any
939898 >;
940899 policies: {
941− deleteAccessPolicy: FunctionReference<
942− "mutation",
943− "internal",
944− { policyId: string; workspaceId: Id<"workspaces"> },
945− any
946− >;
947900 deleteToolRole: FunctionReference<
948901 "mutation",
949902 "internal",
@@ -962,13 +915,13 @@ export declare const internal: { · 12 unmodified lines
962915 { roleId: string; ruleId: string; workspaceId: Id<"workspaces"> },
963916 any
964917 >;
965− listAccessPolicies: FunctionReference<
918+ listRuntimeTargets: FunctionReference<"query", "internal", {}, any>;
919+ listToolPolicies: FunctionReference<
966920 "query",
967921 "internal",
968922 { accountId?: Id<"accounts">; workspaceId: Id<"workspaces"> },
969923 any
970924 >;
971− listRuntimeTargets: FunctionReference<"query", "internal", {}, any>;
972925 listToolRoleBindings: FunctionReference<
973926 "query",
974927 "internal",
@@ -987,29 +940,6 @@ export declare const internal: { · 12 unmodified lines
987940 { workspaceId: Id<"workspaces"> },
988941 any
989942 >;
990− upsertAccessPolicy: FunctionReference<
991− "mutation",
992− "internal",
993− {
994− approvalMode?: "inherit" | "auto" | "required";
995− argumentConditions?: Array<{
996− key: string;
997− operator: "equals" | "contains" | "starts_with" | "not_equals";
998− value: string;
999− }>;
1000− clientId?: string;
1001− effect?: "allow" | "deny";
1002− id?: string;
1003− matchType?: "glob" | "exact";
1004− priority?: number;
1005− resourcePattern: string;
1006− resourceType?: "all_tools" | "source" | "namespace" | "tool_path";
1007− scopeType?: "account" | "organization" | "workspace";
1008− targetAccountId?: Id<"accounts">;
1009− workspaceId: Id<"workspaces">;
1010− },
1011− any
1012− >;
1013943 upsertToolRole: FunctionReference<
1014944 "mutation",
1015945 "internal",
@@ -1230,29 +1160,6 @@ export declare const internal: { · 214 unmodified lines
12301160 any
12311161 >;
12321162 };
1233− upsertAccessPolicy: FunctionReference<
1234− "mutation",
1235− "internal",
1236− {
1237− approvalMode?: "inherit" | "auto" | "required";
1238− argumentConditions?: Array<{
1239− key: string;
1240− operator: "equals" | "contains" | "starts_with" | "not_equals";
1241− value: string;
1242− }>;
1243− clientId?: string;
1244− effect?: "allow" | "deny";
1245− id?: string;
1246− matchType?: "glob" | "exact";
1247− priority?: number;
1248− resourcePattern: string;
1249− resourceType?: "all_tools" | "source" | "namespace" | "tool_path";
1250− scopeType?: "account" | "organization" | "workspace";
1251− targetAccountId?: Id<"accounts">;
1252− workspaceId: Id<"workspaces">;
1253− },
1254− any
1255− >;
12561163 upsertCredential: FunctionReference<
12571164 "mutation",
12581165 "internal",
executor/packages/database/convex/access-controls.test.ts+226−30
@@ -217,10 +217,9 @@ describe("authentication", () => { · 216 unmodified lines
217217 const owner = await seedUser(t, { subject: "owner-2" });
218218
219219 await expect(
220− t.mutation(api.workspace.upsertAccessPolicy, {
220+ t.mutation(api.workspace.upsertToolRole, {
221221 workspaceId: owner.workspaceId,
222− resourcePattern: "*",
223− effect: "allow",
222+ name: "anonymous-role",
224223 }),
225224 ).rejects.toThrow("Must be signed in");
226225 });
@@ -421,7 +420,7 @@ describe("workspace access controls", () => { · 194 unmodified lines
421420 ).rejects.toThrow("You are not a member of this workspace");
422421 });
423422
424− test("regular member cannot upsert access policies (admin-only)", async () => {
423+ test("regular member cannot upsert tool roles (admin-only)", async () => {
425424 const t = setup();
426425 const owner = await seedUser(t, { subject: "ws-admin-owner" });
427426 const member = await seedUser(t, { subject: "ws-plain-member" });
@@ -440,15 +439,14 @@ describe("workspace access controls", () => { · 12 unmodified lines
440439 const authedMember = t.withIdentity({ subject: "ws-plain-member" });
441440
442441 await expect(
443− authedMember.mutation(api.workspace.upsertAccessPolicy, {
442+ authedMember.mutation(api.workspace.upsertToolRole, {
444443 workspaceId: owner.workspaceId,
445− resourcePattern: "*",
446− effect: "allow",
444+ name: "member-managed-role",
447445 }),
448446 ).rejects.toThrow("Only organization admins can perform this action");
449447 });
450448
451− test("workspace admin can upsert access policies", async () => {
449+ test("workspace admin can configure tool policies via roles", async () => {
452450 const t = setup();
453451 const owner = await seedUser(t, { subject: "ws-policy-owner" });
454452 const admin = await seedUser(t, { subject: "ws-policy-admin" });
@@ -466,16 +464,37 @@ describe("workspace access controls", () => { · 11 unmodified lines
466464
467465 const authedAdmin = t.withIdentity({ subject: "ws-policy-admin" });
468466
469− const policy = await authedAdmin.mutation(api.workspace.upsertAccessPolicy, {
467+ const role = await authedAdmin.mutation(api.workspace.upsertToolRole, {
470468 workspaceId: owner.workspaceId,
469+ name: "policy-admin-role",
470+ description: "workspace admin policy role",
471+ });
472+
473+ await authedAdmin.mutation(api.workspace.upsertToolRoleRule, {
474+ workspaceId: owner.workspaceId,
475+ roleId: role.id,
476+ selectorType: "tool_path",
471477 resourcePattern: "*.delete",
472478 effect: "allow",
473479 approvalMode: "required",
474480 });
475481
476− expect(policy.effect).toBe("allow");
477− expect(policy.approvalMode).toBe("required");
478− expect(policy.resourcePattern).toBe("*.delete");
482+ await authedAdmin.mutation(api.workspace.upsertToolRoleBinding, {
483+ workspaceId: owner.workspaceId,
484+ roleId: role.id,
485+ scopeType: "workspace",
486+ status: "active",
487+ });
488+
489+ const policies = await authedAdmin.query(api.workspace.listToolPolicies, {
490+ workspaceId: owner.workspaceId,
491+ });
492+
493+ expect(policies.some((policy: { resourcePattern?: string; effect?: string; approvalMode?: string }) => (
494+ policy.resourcePattern === "*.delete"
495+ && policy.effect === "allow"
496+ && policy.approvalMode === "required"
497+ ))).toBe(true);
479498 });
480499
481500 test("account-scoped policy target must be active org member", async () => {
@@ -484,29 +503,88 @@ describe("workspace access controls", () => { · 2 unmodified lines
484503 const outsider = await seedUser(t, { subject: "policy-target-outsider" });
485504 const authedOwner = t.withIdentity({ subject: "policy-target-owner" });
486505
506+ const role = await authedOwner.mutation(api.workspace.upsertToolRole, {
507+ workspaceId: owner.workspaceId,
508+ name: "policy-target-role",
509+ });
510+
487511 await expect(
488− authedOwner.mutation(api.workspace.upsertAccessPolicy, {
512+ authedOwner.mutation(api.workspace.upsertToolRoleBinding, {
489513 workspaceId: owner.workspaceId,
514+ roleId: role.id,
490515 scopeType: "account",
491516 targetAccountId: outsider.accountId,
492− resourcePattern: "*",
493− effect: "allow",
517+ status: "active",
494518 }),
495519 ).rejects.toThrow("targetAccountId must be an active member of this organization");
496520 });
497521
498− test("workspace owner can upsert access policies", async () => {
522+ test("workspace owner can configure tool policies", async () => {
499523 const t = setup();
500524 const owner = await seedUser(t, { subject: "ws-owner-policy" });
501525 const authedOwner = t.withIdentity({ subject: "ws-owner-policy" });
502526
503− const policy = await authedOwner.mutation(api.workspace.upsertAccessPolicy, {
527+ const role = await authedOwner.mutation(api.workspace.upsertToolRole, {
504528 workspaceId: owner.workspaceId,
505− resourcePattern: "*",
529+ name: "owner-policy-role",
530+ });
531+
532+ await authedOwner.mutation(api.workspace.upsertToolRoleRule, {
533+ workspaceId: owner.workspaceId,
534+ roleId: role.id,
535+ selectorType: "all",
506536 effect: "allow",
537+ approvalMode: "required",
538+ });
539+
540+ await authedOwner.mutation(api.workspace.upsertToolRoleBinding, {
541+ workspaceId: owner.workspaceId,
542+ roleId: role.id,
543+ scopeType: "workspace",
544+ status: "active",
545+ });
546+
547+ const policies = await authedOwner.query(api.workspace.listToolPolicies, {
548+ workspaceId: owner.workspaceId,
549+ });
550+ expect(policies.some((policy: { roleId?: string }) => policy.roleId === role.id)).toBe(true);
551+ });
552+
553+ test("workspace owner can delete role-managed tool policies", async () => {
554+ const t = setup();
555+ const owner = await seedUser(t, { subject: "ws-owner-policy-delete" });
556+ const authedOwner = t.withIdentity({ subject: "ws-owner-policy-delete" });
557+
558+ const role = await authedOwner.mutation(api.workspace.upsertToolRole, {
559+ workspaceId: owner.workspaceId,
560+ name: "owner-policy-delete-role",
561+ });
562+
563+ await authedOwner.mutation(api.workspace.upsertToolRoleRule, {
564+ workspaceId: owner.workspaceId,
565+ roleId: role.id,
566+ selectorType: "tool_path",
567+ resourcePattern: "github.repos.delete",
568+ effect: "deny",
569+ matchType: "exact",
570+ });
571+
572+ await authedOwner.mutation(api.workspace.upsertToolRoleBinding, {
573+ workspaceId: owner.workspaceId,
574+ roleId: role.id,
575+ scopeType: "workspace",
576+ status: "active",
507577 });
508578
509− expect(policy.effect).toBe("allow");
579+ await authedOwner.mutation(api.workspace.deleteToolRole, {
580+ workspaceId: owner.workspaceId,
581+ roleId: role.id,
582+ });
583+
584+ const remaining = await authedOwner.query(api.workspace.listToolPolicies, {
585+ workspaceId: owner.workspaceId,
586+ });
587+ expect(remaining.some((policy: { roleId?: string }) => policy.roleId === role.id)).toBe(false);
510588 });
511589
512590 test("regular member cannot upsert credentials (admin-only)", async () => {
@@ -565,6 +643,60 @@ describe("workspace access controls", () => { · 52 unmodified lines
565643 ).rejects.toThrow("Only organization admins can perform this action");
566644 });
567645
646+ test("cannot hijack tool source id from another organization", async () => {
647+ const t = setup();
648+ const orgAOwner = await seedUser(t, { subject: "tool-source-org-a-owner" });
649+ const orgBOwner = await seedUser(t, { subject: "tool-source-org-b-owner" });
650+
651+ const authedA = t.withIdentity({ subject: "tool-source-org-a-owner" });
652+ const authedB = t.withIdentity({ subject: "tool-source-org-b-owner" });
653+
654+ const sourceB = await authedB.mutation(api.workspace.upsertToolSource, {
655+ workspaceId: orgBOwner.workspaceId,
656+ name: "org-b-source",
657+ type: "mcp",
658+ config: { url: "https://example.com/org-b" },
659+ });
660+
661+ await expect(
662+ authedA.mutation(api.workspace.upsertToolSource, {
663+ workspaceId: orgAOwner.workspaceId,
664+ id: sourceB.id,
665+ name: "org-a-source",
666+ type: "mcp",
667+ config: { url: "https://example.com/org-a" },
668+ }),
669+ ).rejects.toThrow("Tool source id already exists in another organization");
670+ });
671+
672+ test("cannot reuse workspace-scoped tool source id across workspaces", async () => {
673+ const t = setup();
674+ const owner = await seedUser(t, { subject: "tool-source-workspace-owner" });
675+ const authedOwner = t.withIdentity({ subject: "tool-source-workspace-owner" });
676+
677+ const secondWorkspace = await authedOwner.mutation(api.workspaces.create, {
678+ name: "Second Workspace",
679+ organizationId: owner.organizationId,
680+ });
681+
682+ const source = await authedOwner.mutation(api.workspace.upsertToolSource, {
683+ workspaceId: owner.workspaceId,
684+ name: "workspace-one-source",
685+ type: "mcp",
686+ config: { url: "https://example.com/ws1" },
687+ });
688+
689+ await expect(
690+ authedOwner.mutation(api.workspace.upsertToolSource, {
691+ workspaceId: secondWorkspace.id,
692+ id: source.id,
693+ name: "workspace-two-source",
694+ type: "mcp",
695+ config: { url: "https://example.com/ws2" },
696+ }),
697+ ).rejects.toThrow("Tool source id belongs to a different workspace");
698+ });
699+
568700 test("account-scoped credential target must be active org member", async () => {
569701 const t = setup();
570702 const owner = await seedUser(t, { subject: "cred-target-owner" });
@@ -656,15 +788,15 @@ describe("workspace access controls", () => { · 85 unmodified lines
656788 status: "active",
657789 });
658790
659− const policies = await authedOwner.query(api.workspace.listAccessPolicies, {
791+ const policies = await authedOwner.query(api.workspace.listToolPolicies, {
660792 workspaceId: owner.workspaceId,
661793 });
662794
663− expect(policies.some((policy) => policy.resourceType === "source" && policy.resourcePattern === "source:github"))
795+ expect(policies.some((policy: { resourceType?: string; resourcePattern?: string }) => policy.resourceType === "source" && policy.resourcePattern === "source:github"))
664796 .toBe(true);
665797 });
666798
667− test("regular member can read access policies (no admin required)", async () => {
799+ test("regular member can read tool policies (no admin required)", async () => {
668800 const t = setup();
669801 const owner = await seedUser(t, { subject: "read-policy-owner" });
670802 const member = await seedUser(t, { subject: "read-policy-member" });
@@ -682,7 +814,7 @@ describe("workspace access controls", () => { · 11 unmodified lines
682814
683815 const authedMember = t.withIdentity({ subject: "read-policy-member" });
684816
685− const policies = await authedMember.query(api.workspace.listAccessPolicies, {
817+ const policies = await authedMember.query(api.workspace.listToolPolicies, {
686818 workspaceId: owner.workspaceId,
687819 });
688820
@@ -764,6 +896,55 @@ describe("organization access controls", () => { · 75 unmodified lines
764896 ).rejects.toThrow("You are not a member of this organization");
765897 });
766898
899+ test("inactive organization blocks organization queries", async () => {
900+ const t = setup();
901+ const owner = await seedUser(t, { subject: "inactive-org-query-owner" });
902+
903+ await t.run(async (ctx) => {
904+ await ctx.db.patch(owner.organizationId, {
905+ status: "deleted",
906+ updatedAt: Date.now(),
907+ });
908+ });
909+
910+ const authedOwner = t.withIdentity({ subject: "inactive-org-query-owner" });
911+
912+ await expect(
913+ authedOwner.query(api.organizationMembers.list, {
914+ organizationId: owner.organizationId,
915+ }),
916+ ).rejects.toThrow("Organization is inactive");
917+ });
918+
919+ test("inactive organization blocks organization mutations", async () => {
920+ const t = setup();
921+ const owner = await seedUser(t, { subject: "inactive-org-mutation-owner" });
922+ const member = await seedUser(t, { subject: "inactive-org-mutation-member" });
923+
924+ await addOrgMember(t, {
925+ organizationId: owner.organizationId,
926+ accountId: member.accountId,
927+ role: "member",
928+ });
929+
930+ await t.run(async (ctx) => {
931+ await ctx.db.patch(owner.organizationId, {
932+ status: "deleted",
933+ updatedAt: Date.now(),
934+ });
935+ });
936+
937+ const authedOwner = t.withIdentity({ subject: "inactive-org-mutation-owner" });
938+
939+ await expect(
940+ authedOwner.mutation(api.organizationMembers.updateRole, {
941+ organizationId: owner.organizationId,
942+ accountId: member.accountId,
943+ role: "admin",
944+ }),
945+ ).rejects.toThrow("Organization is inactive");
946+ });
947+
767948 test("regular member cannot update roles (admin-only)", async () => {
768949 const t = setup();
769950 const owner = await seedUser(t, { subject: "role-owner" });
@@ -931,7 +1112,7 @@ describe("cross-workspace isolation", () => { · 161 unmodified lines
9311112 ).rejects.toThrow("You are not a member of this workspace");
9321113 });
9331114
934− test("user in workspace A cannot manage policies in workspace B", async () => {
1115+ test("user in workspace A cannot manage tool policies in workspace B", async () => {
9351116 const t = setup();
9361117 await seedUser(t, { subject: "policy-user-a" });
9371118 const userB = await seedUser(t, { subject: "policy-user-b" });
@@ -939,10 +1120,9 @@ describe("cross-workspace isolation", () => { · 1 unmodified line
9391120 const authedA = t.withIdentity({ subject: "policy-user-a" });
9401121
9411122 await expect(
942− authedA.mutation(api.workspace.upsertAccessPolicy, {
1123+ authedA.mutation(api.workspace.upsertToolRole, {
9431124 workspaceId: userB.workspaceId,
944− resourcePattern: "*",
945− effect: "allow",
1125+ name: "cross-workspace-role",
9461126 }),
9471127 ).rejects.toThrow("You are not a member of this workspace");
9481128 });
@@ -1447,13 +1627,29 @@ describe("role hierarchy validation", () => { · 498 unmodified lines
14471627
14481628 const authed = t.withIdentity({ subject: "admin-hier-admin" });
14491629
1450− // Admin can upsert access policies
1451− const policy = await authed.mutation(api.workspace.upsertAccessPolicy, {
1630+ // Admin can manage tool-policy roles/rules/bindings
1631+ const role = await authed.mutation(api.workspace.upsertToolRole, {
1632+ workspaceId: org.workspaceId,
1633+ name: "admin-hierarchy-role",
1634+ });
1635+ await authed.mutation(api.workspace.upsertToolRoleRule, {
14521636 workspaceId: org.workspaceId,
1637+ roleId: role.id,
1638+ selectorType: "tool_path",
14531639 resourcePattern: "admin.*",
14541640 effect: "deny",
14551641 });
1456− expect(policy.effect).toBe("deny");
1642+ await authed.mutation(api.workspace.upsertToolRoleBinding, {
1643+ workspaceId: org.workspaceId,
1644+ roleId: role.id,
1645+ scopeType: "workspace",
1646+ status: "active",
1647+ });
1648+
1649+ const policies = await authed.query(api.workspace.listToolPolicies, {
1650+ workspaceId: org.workspaceId,
1651+ });
1652+ expect(policies.some((policy: { roleId?: string; effect?: string }) => policy.roleId === role.id && policy.effect === "deny")).toBe(true);
14571653
14581654 // Admin can upsert tool sources
14591655 const source = await authed.mutation(api.workspace.upsertToolSource, {
executor/packages/database/convex/app.ts+17−0
@@ -1,4 +1,10 @@
11 import { customQuery, optionalAccountQuery } from "../../core/src/function-builders";
2+import {
3+ CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
4+ LOCAL_BUN_RUNTIME_ID,
5+ defaultRuntimeId,
6+ isRuntimeEnabled,
7+} from "../../core/src/runtimes/runtime-catalog";
28
39 const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);
410
@@ -6,10 +12,21 @@ export const getClientConfig = customQuery({ · 1 unmodified line
612 method: "GET",
713 args: {},
814 handler: async () => {
15+ const allowLocalVm = isRuntimeEnabled(LOCAL_BUN_RUNTIME_ID);
16+ const enabledRuntimeIds = [
17+ ...(allowLocalVm ? [LOCAL_BUN_RUNTIME_ID] : []),
18+ CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
19+ ];
20+
921 return {
1022 authProviderMode: workosEnabled ? "workos" : "local",
1123 invitesProvider: workosEnabled ? "workos" : "disabled",
1224 anonymousAuthIssuer: process.env.ANONYMOUS_AUTH_ISSUER ?? null,
25+ runtime: {
26+ allowLocalVm,
27+ defaultRuntimeId: defaultRuntimeId(),
28+ enabledRuntimeIds,
29+ },
1330 features: {
1431 organizations: true,
1532 billing: true,
executor/packages/database/convex/database.ts+1−3
@@ -34,9 +34,7 @@ export { · 33 unmodified lines
3434 upsertToolRoleBinding,
3535 listToolRoleBindings,
3636 deleteToolRoleBinding,
37− upsertAccessPolicy,
38− deleteAccessPolicy,
39− listAccessPolicies,
37+ listToolPolicies,
4038 } from "./database/policies";
4139 export {
4240 upsertCredential,
executor/packages/database/convex/database/anonymous_session.ts+63−1
@@ -4,6 +4,59 @@ import { internalMutation } from "../_generated/server"; · 3 unmodified lines
44 import { ensureAnonymousIdentity } from "../../src/database/anonymous";
55 import { mapAnonymousContext } from "../../src/database/readers";
66
7+type TrustedClientId = "web" | "mcp";
8+
9+function parseTrustedClientId(value: string | undefined): TrustedClientId | undefined {
10+ const candidate = value?.trim();
11+ if (!candidate) {
12+ return undefined;
13+ }
14+
15+ if (candidate === "web" || candidate === "mcp") {
16+ return candidate;
17+ }
18+
19+ throw new Error("clientId must be one of: web, mcp");
20+}
21+
22+function parseStoredTrustedClientId(value: string | undefined): TrustedClientId | undefined {
23+ const candidate = value?.trim();
24+ if (!candidate) {
25+ return undefined;
26+ }
27+
28+ if (candidate === "web" || candidate === "mcp") {
29+ return candidate;
30+ }
31+
32+ return undefined;
33+}
34+
35+function defaultClientIdForSessionId(sessionId: string): TrustedClientId {
36+ if (sessionId.startsWith("mcp_")) {
37+ return "mcp";
38+ }
39+
40+ return "web";
41+}
42+
43+function resolveTrustedClientId(args: {
44+ sessionId: string;
45+ requestedClientId?: TrustedClientId;
46+ existingClientId?: string;
47+}): TrustedClientId {
48+ if (args.requestedClientId) {
49+ return args.requestedClientId;
50+ }
51+
52+ const existingClientId = parseStoredTrustedClientId(args.existingClientId);
53+ if (existingClientId) {
54+ return existingClientId;
55+ }
56+
57+ return defaultClientIdForSessionId(args.sessionId);
58+}
59+
760 export const bootstrapAnonymousSession = internalMutation({
861 args: {
962 sessionId: v.optional(v.string()),
@@ -14,7 +67,7 @@ export const bootstrapAnonymousSession = internalMutation({ · 4 unmodified lines
1467 const now = Date.now();
1568 const requestedSessionId = args.sessionId?.trim() || "";
1669 const requestedAccountId = args.accountId?.trim() || "";
17− const clientId = args.clientId?.trim() || "web";
70+ const requestedClientId = parseTrustedClientId(args.clientId);
1871
1972 const allowRequestedSessionId = requestedSessionId.startsWith("mcp_")
2073 || requestedSessionId.startsWith("anon_session_");
@@ -26,6 +79,11 @@ export const bootstrapAnonymousSession = internalMutation({ · 5 unmodified lines
2679 .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
2780 .unique();
2881 if (existing) {
82+ const clientId = resolveTrustedClientId({
83+ sessionId,
84+ requestedClientId,
85+ existingClientId: existing.clientId,
86+ });
2987 const identity = await ensureAnonymousIdentity(ctx, {
3088 sessionId,
3189 workspaceId: existing.workspaceId,
@@ -57,6 +115,10 @@ export const bootstrapAnonymousSession = internalMutation({ · 25 unmodified lines
57115 const sessionId = allowRequestedSessionId
58116 ? requestedSessionId as string
59117 : generatedSessionId;
118+ const clientId = resolveTrustedClientId({
119+ sessionId,
120+ requestedClientId,
121+ });
60122
61123 const identity = await ensureAnonymousIdentity(ctx, {
62124 sessionId,
executor/packages/database/convex/database/policies.ts+20−293
@@ -4,7 +4,7 @@ import type { Id } from "../_generated/dataModel"; · 3 unmodified lines
44 import type { MutationCtx, QueryCtx } from "../_generated/server";
55 import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog";
66 import type {
7− AccessPolicyRecord,
7+ ToolPolicyRecord,
88 ToolRoleBindingRecord,
99 ToolRoleRecord,
1010 ToolRoleRuleRecord,
@@ -15,18 +15,17 @@ import { · 4 unmodified lines
1515 policyApprovalModeValidator,
1616 policyEffectValidator,
1717 policyMatchTypeValidator,
18− policyResourceTypeValidator,
1918 policyScopeTypeValidator,
2019 toolRoleBindingStatusValidator,
2120 toolRoleSelectorTypeValidator,
2221 } from "../../src/database/validators";
2322
24−type PolicyResourceType = AccessPolicyRecord["resourceType"];
23+type PolicyResourceType = ToolPolicyRecord["resourceType"];
2524 type DbContext = Pick<MutationCtx, "db"> | Pick<QueryCtx, "db">;
2625
2726 function normalizeArgumentConditions(
28− argumentConditions: AccessPolicyRecord["argumentConditions"] | undefined,
29−): AccessPolicyRecord["argumentConditions"] | undefined {
27+ argumentConditions: ToolPolicyRecord["argumentConditions"] | undefined,
28+): ToolPolicyRecord["argumentConditions"] | undefined {
3029 if (!argumentConditions || argumentConditions.length === 0) {
3130 return undefined;
3231 }
@@ -42,22 +41,6 @@ function normalizeArgumentConditions( · 9 unmodified lines
4241 return normalized.length > 0 ? normalized : undefined;
4342 }
4443
45−function selectorTypeFromResourceType(resourceType: PolicyResourceType): ToolRoleSelectorType {
46− if (resourceType === "all_tools") {
47− return "all";
48− }
49−
50− if (resourceType === "source") {
51− return "source";
52− }
53−
54− if (resourceType === "namespace") {
55− return "namespace";
56− }
57−
58− return "tool_path";
59−}
60−
6144 function resourceTypeFromSelectorType(selectorType: ToolRoleSelectorType): PolicyResourceType {
6245 if (selectorType === "all") {
6346 return "all_tools";
@@ -74,69 +57,6 @@ function resourceTypeFromSelectorType(selectorType: ToolRoleSelectorType): Polic · 10 unmodified lines
7457 return "tool_path";
7558 }
7659
77−function normalizePolicyResourceInput(args: {
78− resourceType?: PolicyResourceType;
79− resourcePattern: string;
80−}): {
81− resourceType: PolicyResourceType;
82− resourcePattern: string;
83−} {
84− const trimmedPattern = args.resourcePattern.trim();
85− const inferredType = trimmedPattern === "*" ? "all_tools" : "tool_path";
86− const resourceType = args.resourceType ?? inferredType;
87−
88− if (resourceType === "all_tools") {
89− return {
90− resourceType,
91− resourcePattern: "*",
92− };
93− }
94−
95− if (trimmedPattern.length === 0) {
96− throw new Error("resourcePattern is required");
97− }
98−
99− return {
100− resourceType,
101− resourcePattern: trimmedPattern,
102− };
103−}
104−
105−function selectorFieldsFromResource(
106− resourceType: PolicyResourceType,
107− resourcePattern: string,
108−): {
109− selectorType: ToolRoleSelectorType;
110− sourceKey?: string;
111− namespacePattern?: string;
112− toolPathPattern?: string;
113−} {
114− if (resourceType === "all_tools") {
115− return {
116− selectorType: "all",
117− };
118− }
119−
120− if (resourceType === "source") {
121− return {
122− selectorType: "source",
123− sourceKey: resourcePattern,
124− };
125− }
126−
127− if (resourceType === "namespace") {
128− return {
129− selectorType: "namespace",
130− namespacePattern: resourcePattern,
131− };
132− }
133−
134− return {
135− selectorType: "tool_path",
136− toolPathPattern: resourcePattern,
137− };
138−}
139−
14060 function resourcePatternFromRule(rule: {
14161 selectorType: ToolRoleSelectorType;
14262 sourceKey?: string;
@@ -183,10 +103,10 @@ function mapToolRoleRule(doc: { · 40 unmodified lines
183103 sourceKey?: string;
184104 namespacePattern?: string;
185105 toolPathPattern?: string;
186− matchType: AccessPolicyRecord["matchType"];
187− effect: AccessPolicyRecord["effect"];
188− approvalMode: AccessPolicyRecord["approvalMode"];
189− argumentConditions?: AccessPolicyRecord["argumentConditions"];
106+ matchType: ToolPolicyRecord["matchType"];
107+ effect: ToolPolicyRecord["effect"];
108+ approvalMode: ToolPolicyRecord["approvalMode"];
109+ argumentConditions?: ToolPolicyRecord["argumentConditions"];
190110 priority: number;
191111 createdAt: number;
192112 updatedAt: number;
@@ -213,7 +133,7 @@ function mapToolRoleBinding(doc: { · 20 unmodified lines
213133 bindingId: string;
214134 roleId: string;
215135 organizationId: Id<"organizations">;
216− scopeType: AccessPolicyRecord["scopeType"];
136+ scopeType: ToolPolicyRecord["scopeType"];
217137 workspaceId?: Id<"workspaces">;
218138 targetAccountId?: Id<"accounts">;
219139 clientId?: string;
@@ -238,9 +158,9 @@ function mapToolRoleBinding(doc: { · 18 unmodified lines
238158 }
239159
240160 function derivedPolicyId(roleId: string, ruleId: string, bindingId: string): string {
241− const rolePrefix = "policy_role_";
242− const rulePrefix = "policy_rule_";
243− const bindingPrefix = "policy_binding_";
161+ const rolePrefix = "tool_policy_role_";
162+ const rulePrefix = "tool_policy_rule_";
163+ const bindingPrefix = "tool_policy_binding_";
244164 if (
245165 roleId.startsWith(rolePrefix)
246166 && ruleId.startsWith(rulePrefix)
@@ -259,24 +179,24 @@ function derivedPolicyId(roleId: string, ruleId: string, bindingId: string): str · 12 unmodified lines
259179
260180 function mapFlattenedPolicy(doc: {
261181 policyId: string;
262− scopeType: AccessPolicyRecord["scopeType"];
182+ scopeType: ToolPolicyRecord["scopeType"];
263183 organizationId: Id<"organizations">;
264184 workspaceId?: Id<"workspaces">;
265185 targetAccountId?: Id<"accounts">;
266186 clientId?: string;
267187 resourceType: PolicyResourceType;
268188 resourcePattern: string;
269− matchType: AccessPolicyRecord["matchType"];
270− effect: AccessPolicyRecord["effect"];
271− approvalMode: AccessPolicyRecord["approvalMode"];
272− argumentConditions?: AccessPolicyRecord["argumentConditions"];
189+ matchType: ToolPolicyRecord["matchType"];
190+ effect: ToolPolicyRecord["effect"];
191+ approvalMode: ToolPolicyRecord["approvalMode"];
192+ argumentConditions?: ToolPolicyRecord["argumentConditions"];
273193 priority: number;
274194 roleId: string;
275195 ruleId: string;
276196 bindingId: string;
277197 createdAt: number;
278198 updatedAt: number;
279−}): AccessPolicyRecord {
199+}): ToolPolicyRecord {
280200 return {
281201 id: doc.policyId,
282202 scopeType: doc.scopeType,
@@ -805,200 +725,7 @@ export const deleteToolRoleBinding = internalMutation({ · 522 unmodified lines
805725 },
806726 });
807727
808−export const upsertAccessPolicy = internalMutation({
809− args: {
810− id: v.optional(v.string()),
811− workspaceId: v.id("workspaces"),
812− scopeType: v.optional(policyScopeTypeValidator),
813− targetAccountId: v.optional(v.id("accounts")),
814− clientId: v.optional(v.string()),
815− resourceType: v.optional(policyResourceTypeValidator),
816− resourcePattern: v.string(),
817− matchType: v.optional(policyMatchTypeValidator),
818− effect: v.optional(policyEffectValidator),
819− approvalMode: v.optional(policyApprovalModeValidator),
820− argumentConditions: v.optional(v.array(argumentConditionValidator)),
821− priority: v.optional(v.number()),
822− },
823− handler: async (ctx, args) => {
824− const now = Date.now();
825− const workspace = await requireWorkspaceWithOrganization(ctx, args.workspaceId);
826− const policyId = args.id ?? `policy_${crypto.randomUUID()}`;
827− const scopeType = args.scopeType ?? "workspace";
828− const targetAccountId = scopeType === "account" ? args.targetAccountId : undefined;
829− if (scopeType === "account" && !targetAccountId) {
830− throw new Error("targetAccountId is required for account-scoped policies");
831− }
832− if (targetAccountId) {
833− await assertActiveOrgMember(ctx, {
834− organizationId: workspace.organizationId,
835− accountId: targetAccountId,
836− fieldLabel: "targetAccountId",
837− });
838− }
839−
840− const resourceInput = normalizePolicyResourceInput({
841− resourceType: args.resourceType,
842− resourcePattern: args.resourcePattern,
843− });
844− const selectorFields = selectorFieldsFromResource(resourceInput.resourceType, resourceInput.resourcePattern);
845−
846− const roleId = `policy_role_${policyId}`;
847− const ruleId = `policy_rule_${policyId}`;
848− const bindingId = `policy_binding_${policyId}`;
849−
850− const existingRole = await ctx.db
851− .query("toolRoles")
852− .withIndex("by_role_id", (q) => q.eq("roleId", roleId))
853− .unique();
854− if (existingRole) {
855− if (existingRole.organizationId !== workspace.organizationId) {
856− throw new Error("Policy role does not belong to this organization");
857− }
858− await ctx.db.patch(existingRole._id, {
859− name: `policy:${policyId}`,
860− description: "Direct access policy",
861− updatedAt: now,
862− });
863− } else {
864− await ctx.db.insert("toolRoles", {
865− roleId,
866− organizationId: workspace.organizationId,
867− name: `policy:${policyId}`,
868− description: "Direct access policy",
869− createdAt: now,
870− updatedAt: now,
871− });
872− }
873−
874− const normalizedConditions = normalizeArgumentConditions(args.argumentConditions);
875− const existingRule = await ctx.db
876− .query("toolRoleRules")
877− .withIndex("by_rule_id", (q) => q.eq("ruleId", ruleId))
878− .unique();
879− const rulePayload = {
880− roleId,
881− organizationId: workspace.organizationId,
882− selectorType: selectorFields.selectorType,
883− sourceKey: selectorFields.sourceKey,
884− namespacePattern: selectorFields.namespacePattern,
885− toolPathPattern: selectorFields.toolPathPattern,
886− matchType: args.matchType ?? "glob",
887− effect: args.effect ?? "allow",
888− approvalMode: args.approvalMode ?? "required",
889− argumentConditions: normalizedConditions,
890− priority: args.priority ?? 100,
891− updatedAt: now,
892− };
893− if (existingRule) {
894− await ctx.db.patch(existingRule._id, rulePayload);
895− } else {
896− await ctx.db.insert("toolRoleRules", {
897− ruleId,
898− ...rulePayload,
899− createdAt: now,
900− });
901− }
902−
903− const existingBinding = await ctx.db
904− .query("toolRoleBindings")
905− .withIndex("by_binding_id", (q) => q.eq("bindingId", bindingId))
906− .unique();
907− const bindingPayload = {
908− roleId,
909− organizationId: workspace.organizationId,
910− scopeType,
911− workspaceId: scopeType === "workspace" ? args.workspaceId : undefined,
912− targetAccountId,
913− clientId: args.clientId?.trim() || undefined,
914− status: "active" as const,
915− expiresAt: undefined,
916− updatedAt: now,
917− };
918− if (existingBinding) {
919− await ctx.db.patch(existingBinding._id, bindingPayload);
920− } else {
921− await ctx.db.insert("toolRoleBindings", {
922− bindingId,
923− ...bindingPayload,
924− createdAt: now,
925− });
926− }
927−
928− return mapFlattenedPolicy({
929− policyId,
930− scopeType,
931− organizationId: workspace.organizationId,
932− workspaceId: scopeType === "workspace" ? args.workspaceId : undefined,
933− targetAccountId,
934− clientId: args.clientId?.trim() || undefined,
935− resourceType: resourceInput.resourceType,
936− resourcePattern: resourceInput.resourcePattern,
937− matchType: args.matchType ?? "glob",
938− effect: args.effect ?? "allow",
939− approvalMode: args.approvalMode ?? "required",
940− argumentConditions: normalizedConditions,
941− priority: args.priority ?? 100,
942− roleId,
943− ruleId,
944− bindingId,
945− createdAt: now,
946− updatedAt: now,
947− });
948− },
949−});
950−
951−export const deleteAccessPolicy = internalMutation({
952− args: {
953− policyId: v.string(),
954− workspaceId: v.id("workspaces"),
955− },
956− handler: async (ctx, args) => {
957− const workspace = await requireWorkspaceWithOrganization(ctx, args.workspaceId);
958− const roleId = `policy_role_${args.policyId}`;
959− const ruleId = `policy_rule_${args.policyId}`;
960− const bindingId = `policy_binding_${args.policyId}`;
961−
962− const [role, rule, binding] = await Promise.all([
963− ctx.db
964− .query("toolRoles")
965− .withIndex("by_role_id", (q) => q.eq("roleId", roleId))
966− .unique(),
967− ctx.db
968− .query("toolRoleRules")
969− .withIndex("by_rule_id", (q) => q.eq("ruleId", ruleId))
970− .unique(),
971− ctx.db
972− .query("toolRoleBindings")
973− .withIndex("by_binding_id", (q) => q.eq("bindingId", bindingId))
974− .unique(),
975− ]);
976−
977− if (
978− !role
979− || role.organizationId !== workspace.organizationId
980− || !rule
981− || rule.organizationId !== workspace.organizationId
982− || !binding
983− || binding.organizationId !== workspace.organizationId
984− ) {
985− throw new Error(`Policy not found: ${args.policyId}`);
986− }
987−
988− if (binding.scopeType === "workspace" && binding.workspaceId !== args.workspaceId) {
989− throw new Error("Policy does not belong to this workspace");
990− }
991−
992− await Promise.all([
993− ctx.db.delete(rule._id),
994− ctx.db.delete(binding._id),
995− ctx.db.delete(role._id),
996− ]);
997− return { ok: true as const };
998− },
999−});
1000−
1001−export const listAccessPolicies = internalQuery({
728+export const listToolPolicies = internalQuery({
1002729 args: {
1003730 workspaceId: v.id("workspaces"),
1004731 accountId: v.optional(v.id("accounts")),
@@ -1020,7 +747,7 @@ export const listAccessPolicies = internalQuery({ · 15 unmodified lines
1020747 }));
1021748 const rulesByRole = new Map(roleIds.map((roleId, index) => [roleId, roleRules[index] ?? []]));
1022749
1023− const policies: AccessPolicyRecord[] = [];
750+ const policies: ToolPolicyRecord[] = [];
1024751 for (const binding of bindings) {
1025752 const rules = rulesByRole.get(binding.roleId) ?? [];
1026753 for (const rule of rules) {
executor/packages/database/convex/database/tool_sources.ts+12−0
@@ -61,6 +61,18 @@ export const upsertToolSource = internalMutation({ · 60 unmodified lines
6161 }
6262
6363 if (existing) {
64+ if (existing.organizationId !== organizationId) {
65+ throw new Error("Tool source id already exists in another organization");
66+ }
67+
68+ if (existing.scopeType !== scopeType) {
69+ throw new Error("Tool source scope cannot be changed for an existing id");
70+ }
71+
72+ if (scopeType === "workspace" && existing.workspaceId !== args.workspaceId) {
73+ throw new Error("Tool source id belongs to a different workspace");
74+ }
75+
6476 await ctx.db.patch(existing._id, {
6577 scopeType,
6678 organizationId,
executor/packages/database/convex/executor.ts+0−1
@@ -21,7 +21,6 @@ export const createTask = customAction({ · 20 unmodified lines
2121 workspaceId: v.id("workspaces"),
2222 sessionId: v.optional(v.string()),
2323 accountId: v.optional(v.id("accounts")),
24− clientId: v.optional(v.string()),
2524 waitForResult: v.optional(v.boolean()),
2625 },
2726 handler: async (ctx, args) => {
executor/packages/database/convex/executorNode.ts+7−9
@@ -28,7 +28,6 @@ export const listToolsWithWarnings = customAction({ · 27 unmodified lines
2828 args: {
2929 workspaceId: v.id("workspaces"),
3030 accountId: v.optional(v.id("accounts")),
31− clientId: v.optional(v.string()),
3231 sessionId: v.optional(v.string()),
3332 includeDetails: v.optional(v.boolean()),
3433 includeSourceMeta: v.optional(v.boolean()),
@@ -73,14 +72,14 @@ export const listToolsWithWarnings = customAction({ · 38 unmodified lines
7372 await rebuildWorkspaceToolInventoryForContext(ctx, {
7473 workspaceId: args.workspaceId,
7574 accountId: access.accountId,
76− clientId: args.clientId,
75+ clientId: access.clientId,
7776 });
7877 }
7978
8079 const inventory = await listToolsWithWarningsForContext(ctx, {
8180 workspaceId: args.workspaceId,
8281 accountId: access.accountId,
83− clientId: args.clientId,
82+ clientId: access.clientId,
8483 }, {
8584 includeDetails: args.includeDetails ?? false,
8685 includeSourceMeta: args.includeSourceMeta ?? (args.toolPaths ? false : true),
@@ -150,7 +149,6 @@ export const previewOpenApiSourceUpgrade = customAction({ · 63 unmodified lines
150149 args: {
151150 workspaceId: v.id("workspaces"),
152151 accountId: v.optional(v.id("accounts")),
153− clientId: v.optional(v.string()),
154152 sessionId: v.optional(v.string()),
155153 sourceId: v.string(),
156154 name: v.string(),
@@ -165,11 +163,11 @@ export const previewOpenApiSourceUpgrade = customAction({ · 8 unmodified lines
165163
166164 return await previewOpenApiSourceUpgradeForContext(
167165 ctx,
168− {
169− workspaceId: args.workspaceId,
170− accountId: access.accountId,
171− clientId: args.clientId,
172− },
166+ {
167+ workspaceId: args.workspaceId,
168+ accountId: access.accountId,
169+ clientId: access.clientId,
170+ },
173171 {
174172 sourceId: args.sourceId,
175173 name: args.name,
executor/packages/database/convex/http/mcp_auth.ts+2−4
@@ -16,7 +16,6 @@ type VerifiedMcpToken = { provider: "workos"; subject: string }; · 15 unmodified lines
1616 type ParsedMcpContext = {
1717 workspaceId?: Id<"workspaces">;
1818 accountId?: string;
19− clientId?: string;
2019 sessionId?: string;
2120 };
2221
@@ -148,10 +147,9 @@ export function parseMcpContext(url: URL): ParsedMcpContext | undefined { · 125 unmodified lines
148147 const raw = url.searchParams.get("workspaceId");
149148 const workspaceId = raw ? parseWorkspaceId(raw) : undefined;
150149 const accountId = url.searchParams.get("accountId") ?? undefined;
151− const clientId = url.searchParams.get("clientId") ?? undefined;
152150 const sessionId = url.searchParams.get("sessionId") ?? undefined;
153− if (!workspaceId && !accountId && !clientId && !sessionId) {
151+ if (!workspaceId && !accountId && !sessionId) {
154152 return undefined;
155153 }
156− return { workspaceId, accountId, clientId, sessionId };
154+ return { workspaceId, accountId, sessionId };
157155 }
executor/packages/database/convex/http/mcp_handler.ts+3−3
@@ -103,7 +103,7 @@ function createMcpHandler(mode: McpEndpointMode) { · 102 unmodified lines
103103 context = {
104104 workspaceId,
105105 accountId: access.accountId,
106− clientId: requestedContext?.clientId,
106+ clientId: "mcp",
107107 };
108108 } catch (error) {
109109 return Response.json(
@@ -144,7 +144,7 @@ function createMcpHandler(mode: McpEndpointMode) { · 34 unmodified lines
144144 context = {
145145 workspaceId,
146146 accountId: access.accountId,
147− clientId: requestedContext?.clientId,
147+ clientId: "mcp",
148148 };
149149 } else {
150150 if (mcpAuthConfig.enabled && !requestedContext?.sessionId) {
@@ -166,7 +166,7 @@ function createMcpHandler(mode: McpEndpointMode) { · 15 unmodified lines
166166 context = {
167167 workspaceId,
168168 accountId: access.accountId,
169− clientId: requestedContext?.clientId,
169+ clientId: "mcp",
170170 sessionId: requestedContext?.sessionId,
171171 };
172172 }
executor/packages/database/convex/http/mcp_service.ts+4−1
@@ -78,7 +78,10 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 77 unmodified lines
7878 };
7979 },
8080 bootstrapAnonymousContext: async (sessionId?: string): Promise<AnonymousContext> => {
81− return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId });
81+ return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {
82+ sessionId,
83+ clientId: "mcp",
84+ });
8285 },
8386 listTools: async (
8487 toolContext?: { workspaceId: Id<"workspaces">; accountId?: Id<"accounts">; clientId?: string },
executor/packages/database/convex/invites.ts+0−9
@@ -53,7 +53,6 @@ export const create = organizationMutation({ · 52 unmodified lines
5353 args: {
5454 email: v.string(),
5555 role: organizationRoleValidator,
56− workspaceId: v.optional(v.id("workspaces")),
5756 expiresInDays: v.optional(v.number()),
5857 },
5958 handler: async (ctx, args) => {
@@ -78,13 +77,6 @@ export const create = organizationMutation({ · 18 unmodified lines
7877 const expiresAt = now + (args.expiresInDays ?? 7) * 24 * 60 * 60 * 1000;
7978 const normalizedEmail = args.email.toLowerCase().trim();
8079
81− if (args.workspaceId) {
82− const workspace = await ctx.db.get(args.workspaceId);
83− if (workspace?.organizationId !== ctx.organizationId) {
84− throw new Error("Workspace does not belong to this organization");
85− }
86− }
87−
8880 if (ctx.account.provider !== "workos") {
8981 throw new Error("Inviter is not linked to WorkOS");
9082 }
@@ -92,7 +84,6 @@ export const create = organizationMutation({ · 1 unmodified line
9284
9385 const inviteId = await ctx.db.insert("invites", {
9486 organizationId: ctx.organizationId,
95− ...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
9687 email: normalizedEmail,
9788 role: args.role,
9889 status: "pending",
executor/packages/database/convex/workspace.ts+3−70
@@ -9,7 +9,6 @@ import { · 8 unmodified lines
99 policyApprovalModeValidator,
1010 policyEffectValidator,
1111 policyMatchTypeValidator,
12− policyResourceTypeValidator,
1312 policyScopeTypeValidator,
1413 toolRoleBindingStatusValidator,
1514 toolRoleSelectorTypeValidator,
@@ -56,7 +55,6 @@ export const bootstrapAnonymousSession = customMutation({ · 40 unmodified lines
5655 args: {
5756 sessionId: v.optional(v.string()),
5857 accountId: v.optional(v.string()),
59− clientId: v.optional(v.string()),
6058 },
6159 handler: async (ctx, args) => {
6260 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, args);
@@ -127,75 +125,11 @@ export const listPendingApprovals = workspaceQuery({ · 64 unmodified lines
127125 },
128126 });
129127
130−export const upsertAccessPolicy = workspaceMutation({
131− method: "POST",
132− requireAdmin: true,
133− args: {
134− id: v.optional(v.string()),
135− scopeType: v.optional(policyScopeTypeValidator),
136− targetAccountId: v.optional(v.id("accounts")),
137− clientId: v.optional(v.string()),
138− resourceType: v.optional(policyResourceTypeValidator),
139− resourcePattern: v.string(),
140− matchType: v.optional(policyMatchTypeValidator),
141− effect: v.optional(policyEffectValidator),
142− approvalMode: v.optional(policyApprovalModeValidator),
143− argumentConditions: v.optional(v.array(v.object({
144− key: v.string(),
145− operator: v.union(v.literal("equals"), v.literal("contains"), v.literal("starts_with"), v.literal("not_equals")),
146− value: v.string(),
147− }))),
148− priority: v.optional(v.number()),
149− },
150− handler: async (ctx, args) => {
151− if (args.scopeType === "organization") {
152− const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id);
153− if (!organizationMembership || !isAdminRole(organizationMembership.role)) {
154− throw new Error("Only organization admins can create organization-level policies");
155− }
156− }
157−
158− if (args.scopeType === "account") {
159− if (!args.targetAccountId) {
160− throw new Error("targetAccountId is required for account-scoped policies");
161− }
162−
163− const targetMembership = await getOrganizationMembership(
164− ctx,
165− ctx.workspace.organizationId,
166− args.targetAccountId,
167− );
168− if (!targetMembership || targetMembership.status !== "active") {
169− throw new Error("targetAccountId must be an active member of this organization");
170− }
171− }
172−
173− return await ctx.runMutation(internal.database.upsertAccessPolicy, {
174− ...args,
175− workspaceId: ctx.workspaceId,
176− });
177− },
178−});
179−
180−export const deleteAccessPolicy = workspaceMutation({
181− method: "POST",
182− requireAdmin: true,
183− args: {
184− policyId: v.string(),
185− },
186− handler: async (ctx, args) => {
187− return await ctx.runMutation(internal.database.deleteAccessPolicy, {
188− policyId: args.policyId,
189− workspaceId: ctx.workspaceId,
190− });
191− },
192−});
193−
194−export const listAccessPolicies = workspaceQuery({
128+export const listToolPolicies = workspaceQuery({
195129 method: "GET",
196130 args: {},
197131 handler: async (ctx) => {
198− return await ctx.runQuery(internal.database.listAccessPolicies, {
132+ return await ctx.runQuery(internal.database.listToolPolicies, {
199133 workspaceId: ctx.workspaceId,
200134 accountId: ctx.account._id,
201135 });
@@ -480,7 +414,6 @@ export const getToolDetails = workspaceMutation({ · 278 unmodified lines
480414 method: "POST",
481415 args: {
482416 toolPaths: v.array(v.string()),
483− clientId: v.optional(v.string()),
484417 },
485418 handler: async (ctx, args) => {
486419 return await listToolDetailsForContext(
@@ -488,7 +421,7 @@ export const getToolDetails = workspaceMutation({ · 1 unmodified line
488421 {
489422 workspaceId: ctx.workspaceId,
490423 accountId: ctx.account._id,
491− clientId: args.clientId,
424+ clientId: "web",
492425 },
493426 {
494427 toolPaths: args.toolPaths,
executor/packages/database/src/auth/account_identity.ts+18−0
@@ -17,3 +17,21 @@ export function assertMatchesCanonicalAccountId( · 16 unmodified lines
1717 throw new Error(`${fieldName} must match the authenticated workspace account`);
1818 }
1919 }
20+
21+type WorkspaceProvider = "workos" | "anonymous";
22+
23+export function canonicalClientIdForWorkspaceAccess(args: {
24+ provider: WorkspaceProvider;
25+ sessionId?: string;
26+}): "web" | "mcp" {
27+ const sessionId = args.sessionId?.trim() || "";
28+ if (sessionId.startsWith("mcp_")) {
29+ return "mcp";
30+ }
31+
32+ if (args.provider === "anonymous" && sessionId.startsWith("anon_session_")) {
33+ return "web";
34+ }
35+
36+ return "web";
37+}
executor/packages/database/src/auth/event_handlers.ts+17−0
@@ -270,6 +270,23 @@ export const workosEventHandlers = { · 269 unmodified lines
270270 status: "deleted",
271271 updatedAt: now,
272272 });
273+
274+ const memberships = await ctx.db
275+ .query("organizationMembers")
276+ .withIndex("by_account", (q) => q.eq("accountId", account._id))
277+ .collect();
278+
279+ await Promise.all(memberships.map(async (membership) => {
280+ if (membership.status === "removed" && !membership.billable) {
281+ return;
282+ }
283+
284+ await ctx.db.patch(membership._id, {
285+ status: "removed",
286+ billable: false,
287+ updatedAt: now,
288+ });
289+ }));
273290 },
274291
275292 "organization.created": async (ctx, event) => {
executor/packages/database/src/executor/handlers.ts+6−2
@@ -5,6 +5,7 @@ import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../ · 4 unmodified lines
55 import {
66 assertMatchesCanonicalAccountId,
77 canonicalAccountIdForWorkspaceAccess,
8+ canonicalClientIdForWorkspaceAccess,
89 } from "../auth/account_identity";
910 import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";
1011 import { createTaskEvent } from "../task/events";
@@ -227,7 +228,6 @@ export async function createTaskHandler( · 216 unmodified lines
227228 workspaceId: Id<"workspaces">;
228229 sessionId?: string;
229230 accountId?: Id<"accounts">;
230− clientId?: string;
231231 waitForResult?: boolean;
232232 },
233233 ): Promise<TaskExecutionOutcome> {
@@ -237,6 +237,10 @@ export async function createTaskHandler( · 3 unmodified lines
237237 });
238238
239239 const canonicalAccountId = canonicalAccountIdForWorkspaceAccess(access);
240+ const canonicalClientId = canonicalClientIdForWorkspaceAccess({
241+ provider: access.provider,
242+ sessionId: args.sessionId,
243+ });
240244 assertMatchesCanonicalAccountId(args.accountId, canonicalAccountId);
241245
242246 const waitForResult = args.waitForResult ?? false;
@@ -247,7 +251,7 @@ export async function createTaskHandler( · 4 unmodified lines
247251 metadata: toMetadata(args.metadata),
248252 workspaceId: args.workspaceId,
249253 accountId: canonicalAccountId,
250− clientId: args.clientId,
254+ clientId: canonicalClientId,
251255 scheduleAfterCreate: !waitForResult,
252256 });
253257
executor/packages/database/src/runtime/account_auth.ts+7−1
@@ -4,6 +4,7 @@ import { internal } from "../../convex/_generated/api"; · 3 unmodified lines
44 import {
55 assertMatchesCanonicalAccountId,
66 canonicalAccountIdForWorkspaceAccess,
7+ canonicalClientIdForWorkspaceAccess,
78 } from "../auth/account_identity";
89
910 export async function requireCanonicalAccount(
@@ -13,14 +14,19 @@ export async function requireCanonicalAccount( · 3 unmodified lines
1314 sessionId?: string;
1415 accountId?: Id<"accounts">;
1516 },
16−): Promise<{ accountId: Id<"accounts"> }> {
17+): Promise<{ accountId: Id<"accounts">; clientId: "web" | "mcp" }> {
1718 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
1819 workspaceId: args.workspaceId,
1920 sessionId: args.sessionId,
2021 });
2122 const canonicalAccountId = canonicalAccountIdForWorkspaceAccess(access);
23+ const canonicalClientId = canonicalClientIdForWorkspaceAccess({
24+ provider: access.provider,
25+ sessionId: args.sessionId,
26+ });
2227 assertMatchesCanonicalAccountId(args.accountId, canonicalAccountId);
2328 return {
2429 accountId: canonicalAccountId,
30+ clientId: canonicalClientId,
2531 };
2632 }
executor/packages/database/src/runtime/policy.test.ts+33−5
@@ -1,8 +1,8 @@
11 import { expect, test } from "bun:test";
2−import type { AccessPolicyRecord } from "../../../core/src/types";
2+import type { ToolPolicyRecord } from "../../../core/src/types";
33 import { getDecisionForContext } from "./policy";
44
5−const basePolicy: Omit<AccessPolicyRecord, "id" | "resourceType" | "resourcePattern"> = {
5+const basePolicy: Omit<ToolPolicyRecord, "id" | "resourceType" | "resourcePattern"> = {
66 scopeType: "organization",
77 organizationId: "org_1" as any,
88 matchType: "glob",
@@ -14,7 +14,7 @@ const basePolicy: Omit<AccessPolicyRecord, "id" | "resourceType" | "resourcePatt · 5 unmodified lines
1414 };
1515
1616 test("source-scoped policy can deny all tools from a source", () => {
17− const policies: AccessPolicyRecord[] = [
17+ const policies: ToolPolicyRecord[] = [
1818 {
1919 id: "p1",
2020 ...basePolicy,
@@ -42,7 +42,7 @@ test("source-scoped policy can deny all tools from a source", () => { · 21 unmodified lines
4242 });
4343
4444 test("tool-path policy can override source policy by specificity", () => {
45− const policies: AccessPolicyRecord[] = [
45+ const policies: ToolPolicyRecord[] = [
4646 {
4747 id: "p-source",
4848 ...basePolicy,
@@ -81,7 +81,7 @@ test("tool-path policy can override source policy by specificity", () => { · 32 unmodified lines
8181 });
8282
8383 test("all-tools policy applies without source context", () => {
84− const policies: AccessPolicyRecord[] = [
84+ const policies: ToolPolicyRecord[] = [
8585 {
8686 id: "p-all",
8787 ...basePolicy,
@@ -106,3 +106,31 @@ test("all-tools policy applies without source context", () => { · 18 unmodified lines
106106
107107 expect(decision).toBe("require_approval");
108108 });
109+
110+test("discover can be denied by policy", () => {
111+ const policies: ToolPolicyRecord[] = [
112+ {
113+ id: "p-discover-deny",
114+ ...basePolicy,
115+ resourceType: "tool_path",
116+ resourcePattern: "discover",
117+ matchType: "exact",
118+ effect: "deny",
119+ approvalMode: "required",
120+ },
121+ ];
122+
123+ const decision = getDecisionForContext(
124+ {
125+ path: "discover",
126+ approval: "auto",
127+ },
128+ {
129+ workspaceId: "ws_1",
130+ accountId: "acct_1",
131+ },
132+ policies,
133+ );
134+
135+ expect(decision).toBe("deny");
136+});
executor/packages/database/src/runtime/policy.ts+7−11
@@ -1,5 +1,5 @@
11 import type {
2− AccessPolicyRecord,
2+ ToolPolicyRecord,
33 ArgumentCondition,
44 PolicyDecision,
55 TaskRecord,
@@ -46,7 +46,7 @@ function matchesArgumentConditions( · 40 unmodified lines
4646 return conditions.every((condition) => matchesArgumentCondition(condition, input[condition.key]));
4747 }
4848
49−function matchesPolicyResource(policy: AccessPolicyRecord, tool: PolicyTool): boolean {
49+function matchesPolicyResource(policy: ToolPolicyRecord, tool: PolicyTool): boolean {
5050 if (policy.resourceType === "all_tools") {
5151 return true;
5252 }
@@ -66,7 +66,7 @@ function matchesPolicyResource(policy: AccessPolicyRecord, tool: PolicyTool): bo · 13 unmodified lines
6666 }
6767
6868 function policySpecificity(
69− policy: AccessPolicyRecord,
69+ policy: ToolPolicyRecord,
7070 context: { workspaceId: string; accountId?: string; clientId?: string },
7171 ): number {
7272 const scopeType = policy.scopeType;
@@ -91,7 +91,7 @@ function policySpecificity( · 18 unmodified lines
9191 return score;
9292 }
9393
94−function resolvePolicyDecision(policy: AccessPolicyRecord, defaultDecision: PolicyDecision): PolicyDecision {
94+function resolvePolicyDecision(policy: ToolPolicyRecord, defaultDecision: PolicyDecision): PolicyDecision {
9595 const effect = policy.effect;
9696 const approvalMode = policy.approvalMode;
9797
@@ -113,13 +113,9 @@ function resolvePolicyDecision(policy: AccessPolicyRecord, defaultDecision: Poli · 15 unmodified lines
113113 export function getDecisionForContext(
114114 tool: PolicyTool,
115115 context: { workspaceId: string; accountId?: string; clientId?: string },
116− policies: AccessPolicyRecord[],
116+ policies: ToolPolicyRecord[],
117117 input?: Record<string, unknown>,
118118 ): PolicyDecision {
119− if (tool.path === "discover") {
120− return "allow";
121− }
122−
123119 const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";
124120 const candidates = policies
125121 .filter((policy) => {
@@ -151,7 +147,7 @@ export function getDecisionForContext( · 25 unmodified lines
151147 export function getToolDecision(
152148 task: TaskRecord,
153149 tool: PolicyTool,
154− policies: AccessPolicyRecord[],
150+ policies: ToolPolicyRecord[],
155151 input?: Record<string, unknown>,
156152 ): PolicyDecision {
157153 return getDecisionForContext(
@@ -170,7 +166,7 @@ export function isToolAllowedForTask( · 12 unmodified lines
170166 task: TaskRecord,
171167 toolPath: string,
172168 workspaceTools: ReadonlyMap<string, PolicyTool>,
173− policies: AccessPolicyRecord[],
169+ policies: ToolPolicyRecord[],
174170 ): boolean {
175171 const tool = workspaceTools.get(toolPath);
176172 if (!tool) return false;
executor/packages/database/src/runtime/tool_call_resolution.ts+2−2
@@ -3,7 +3,7 @@ import { z } from "zod"; · 2 unmodified lines
33 import type { ActionCtx } from "../../convex/_generated/server";
44 import { internal } from "../../convex/_generated/api";
55 import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";
6−import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
6+import type { ToolPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
77 import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";
88 import { getDecisionForContext, getToolDecision } from "./policy";
99 import { getReadyRegistryBuildIdResult } from "./tool_registry_state";
@@ -79,7 +79,7 @@ export function getGraphqlDecision( · 69 unmodified lines
7979 tool: { path: string; approval: "auto" | "required"; source?: string; _graphqlSource?: string },
8080 input: unknown,
8181 workspaceTools: Map<string, { path: string; approval: "auto" | "required"; source?: string }> | undefined,
82− policies: AccessPolicyRecord[],
82+ policies: ToolPolicyRecord[],
8383 ): { decision: PolicyDecision; effectivePaths: string[] } {
8484 const sourceName = tool._graphqlSource;
8585 if (!sourceName) {
executor/packages/database/src/runtime/tool_descriptors.ts+2−2
@@ -1,5 +1,5 @@
11 import type {
2− AccessPolicyRecord,
2+ ToolPolicyRecord,
33 OpenApiSourceQuality,
44 ToolDefinition,
55 ToolDescriptor,
@@ -135,7 +135,7 @@ export function computeOpenApiSourceQuality( · 129 unmodified lines
135135 export function listVisibleToolDescriptors(
136136 workspaceTools: Map<string, ToolDefinition>,
137137 context: { workspaceId: string; accountId?: string; clientId?: string },
138− policies: AccessPolicyRecord[],
138+ policies: ToolPolicyRecord[],
139139 options: { includeDetails?: boolean; toolPaths?: string[] } = {},
140140 ): ToolDescriptor[] {
141141 const requestedPaths = options.toolPaths ?? [];
executor/packages/database/src/runtime/tool_invocation.ts+29−6
@@ -2,7 +2,7 @@ import { z } from "zod"; · 1 unmodified line
22 import type { ActionCtx } from "../../convex/_generated/server";
33 import { internal } from "../../convex/_generated/api";
44 import type {
5− AccessPolicyRecord,
5+ ToolPolicyRecord,
66 PolicyDecision,
77 ResolvedToolCredential,
88 TaskRecord,
@@ -218,15 +218,15 @@ async function upsertRequestedToolCall( · 209 unmodified lines
218218 return persistedCall;
219219 }
220220
221−async function listWorkspaceAccessPolicies(
221+async function listWorkspaceToolPolicies(
222222 ctx: ActionCtx,
223223 task: Pick<TaskRecord, "workspaceId" | "accountId">,
224−): Promise<AccessPolicyRecord[]> {
225− const policies = await ctx.runQuery(internal.database.listAccessPolicies, {
224+): Promise<ToolPolicyRecord[]> {
225+ const policies = await ctx.runQuery(internal.database.listToolPolicies, {
226226 workspaceId: task.workspaceId,
227227 accountId: task.accountId,
228228 });
229− return policies as AccessPolicyRecord[];
229+ return policies as ToolPolicyRecord[];
230230 }
231231
232232 async function listRegistryNamespaces(
@@ -348,7 +348,7 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 115 unmodified lines
348348
349349 let effectiveToolPath = toolPath;
350350 try {
351− const typedPolicies = await listWorkspaceAccessPolicies(ctx, task);
351+ const typedPolicies = await listWorkspaceToolPolicies(ctx, task);
352352 const finalizeImmediateTool = async (value: unknown): Promise<unknown> => {
353353 if (persistedCall.status === "requested") {
354354 await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
@@ -368,6 +368,29 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 13 unmodified lines
368368
369369 // Fast system tools are handled server-side from the registry.
370370 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
371+ const systemToolDecision = getDecisionForContext(
372+ {
373+ path: toolPath,
374+ approval: "auto",
375+ },
376+ {
377+ workspaceId: task.workspaceId,
378+ accountId: task.accountId,
379+ clientId: task.clientId,
380+ },
381+ typedPolicies,
382+ );
383+ if (systemToolDecision === "deny") {
384+ const deniedMessage = `${toolPath} (policy denied)`;
385+ await denyToolCall(ctx, {
386+ task,
387+ callId,
388+ toolPath,
389+ deniedMessage,
390+ reason: "policy_deny",
391+ });
392+ }
393+
371394 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
372395 workspaceId: task.workspaceId,
373396 accountId: task.accountId,
executor/packages/database/src/runtime/workspace_tools.ts+8−8
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
1818 import { parseSerializedTool, type SerializedTool } from "../../../core/src/tool/source-serialization";
1919 import type { ExternalToolSourceConfig } from "../../../core/src/tool/source-types";
2020 import type {
21− AccessPolicyRecord,
21+ ToolPolicyRecord,
2222 JsonSchema,
2323 OpenApiSourceQuality,
2424 SourceAuthProfile,
@@ -66,12 +66,12 @@ async function listWorkspaceToolSources( · 41 unmodified lines
6666 return sources;
6767 }
6868
69−async function listWorkspaceAccessPolicies(
69+async function listWorkspaceToolPolicies(
7070 ctx: QueryRunnerCtx,
7171 workspaceId: Id<"workspaces">,
7272 accountId?: Id<"accounts">,
73−): Promise<AccessPolicyRecord[]> {
74− const policies: AccessPolicyRecord[] = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId, accountId });
73+): Promise<ToolPolicyRecord[]> {
74+ const policies: ToolPolicyRecord[] = await ctx.runQuery(internal.database.listToolPolicies, { workspaceId, accountId });
7575 return policies;
7676 }
7777
@@ -378,7 +378,7 @@ function toToolDetailDescriptor(tool: ToolDescriptor): ToolDetailDescriptor { · 300 unmodified lines
378378 function listVisibleRegistryToolDescriptors(
379379 entries: RegistryToolEntry[],
380380 context: { workspaceId: string; accountId?: string; clientId?: string },
381− policies: AccessPolicyRecord[],
381+ policies: ToolPolicyRecord[],
382382 options: {
383383 includeDetails?: boolean;
384384 toolPaths?: string[];
@@ -1151,7 +1151,7 @@ export async function getWorkspaceInventoryProgressForContext( · 766 unmodified lines
11511151 // Read policies so Convex tracks this reactive dependency — any policy
11521152 // change will invalidate the query and produce a new reactiveKey, which
11531153 // in turn causes the client-side TanStack query to re-fetch tool data.
1154− listWorkspaceAccessPolicies(ctx, workspaceId),
1154+ listWorkspaceToolPolicies(ctx, workspaceId),
11551155 ]);
11561156
11571157 const progress = computeWorkspaceInventoryProgress(
@@ -1368,7 +1368,7 @@ async function loadWorkspaceToolInventoryForContext( · 210 unmodified lines
13681368 buildId: options.buildId,
13691369 fetchAll: options.fetchAll,
13701370 }),
1371− listWorkspaceAccessPolicies(ctx, context.workspaceId, context.accountId),
1371+ listWorkspaceToolPolicies(ctx, context.workspaceId, context.accountId),
13721372 ]);
13731373
13741374 const warnings = [...result.warnings];
@@ -1468,7 +1468,7 @@ export async function listToolDetailsForContext( · 93 unmodified lines
14681468 ctx.runQuery(internal.toolRegistry.getState, {
14691469 workspaceId: context.workspaceId,
14701470 }),
1471− listWorkspaceAccessPolicies(ctx, context.workspaceId, context.accountId),
1471+ listWorkspaceToolPolicies(ctx, context.workspaceId, context.accountId),
14721472 ]);
14731473
14741474 const result: Record<string, ToolDetailDescriptor> = {};
executor/packages/runner-sandbox-host/src/bridge.ts+34−48
@@ -1,7 +1,7 @@
11 import { Result } from "better-result";
22 import { api } from "@executor/database/convex/_generated/api";
33 import { decodeToolCallResultFromTransport } from "../../core/src/tool-call-result-transport";
4−import { ConvexClient, ConvexHttpClient } from "convex/browser";
4+import { ConvexHttpClient } from "convex/browser";
55 import { z } from "zod";
66 import type {
77 BridgeEntrypointContext,
@@ -74,56 +74,42 @@ function createConvexClient(callbackConvexUrl: string): ConvexHttpClient { · 66 unmodified lines
7474 });
7575 }
7676
77−function createRealtimeClient(callbackConvexUrl: string): ConvexClient {
78− return new ConvexClient(callbackConvexUrl, {
79− skipConvexDeploymentUrlCheck: true,
80− });
77+function sleep(ms: number): Promise<void> {
78+ return new Promise((resolve) => setTimeout(resolve, ms));
8179 }
8280
8381 async function waitForApprovalUpdate(props: BridgeProps, approvalId: string): Promise<void> {
84− const client = createRealtimeClient(props.callbackConvexUrl);
85−
86− await new Promise<void>((resolve, reject) => {
87− let settled = false;
88−
89− const timer = setTimeout(() => {
90− if (settled) return;
91− settled = true;
92− unsubscribe();
93− client.close();
94− reject(new Error(`Timed out waiting for approval update: ${approvalId}`));
95− }, APPROVAL_SUBSCRIPTION_TIMEOUT_MS);
96−
97− const unsubscribe = client.onUpdate(
98− api.runtimeCallbacks.getApprovalStatus,
99− {
100− internalSecret: props.callbackInternalSecret,
101− runId: props.taskId,
102− approvalId,
103− },
104− (value: { status?: "pending" | "approved" | "denied" | "missing" } | null | undefined) => {
105− const status = value?.status;
106− if (!status || status === "pending") {
107− return;
108− }
109− if (status === "missing") {
110− if (settled) return;
111− settled = true;
112− clearTimeout(timer);
113− unsubscribe();
114− client.close();
115− reject(new Error(`Approval not found: ${approvalId}`));
116− return;
117− }
118− if (settled) return;
119− settled = true;
120− clearTimeout(timer);
121− unsubscribe();
122− client.close();
123− resolve();
124− },
125− );
126− });
82+ const client = createConvexClient(props.callbackConvexUrl);
83+ const startedAt = Date.now();
84+ const pollIntervalMs = 1000;
85+
86+ while (Date.now() - startedAt < APPROVAL_SUBSCRIPTION_TIMEOUT_MS) {
87+ const statusResult = await Result.tryPromise(() => client.query(api.runtimeCallbacks.getApprovalStatus, {
88+ internalSecret: props.callbackInternalSecret,
89+ runId: props.taskId,
90+ approvalId,
91+ }));
92+
93+ if (statusResult.isErr()) {
94+ const cause = statusResult.error.cause;
95+ const message = cause instanceof Error ? cause.message : String(cause);
96+ throw new Error(`Approval status poll failed: ${message}`);
97+ }
98+
99+ const status = statusResult.value?.status;
100+ if (!status || status === "pending") {
101+ await sleep(pollIntervalMs);
102+ continue;
103+ }
104+
105+ if (status === "missing") {
106+ throw new Error(`Approval not found: ${approvalId}`);
107+ }
108+
109+ return;
110+ }
111+
112+ throw new Error(`Timed out waiting for approval update: ${approvalId}`);
127113 }
128114
129115 export async function callToolWithBridge(
executor/packages/runner-sandbox-host/src/index.ts+4−2
@@ -35,6 +35,7 @@ · 34 unmodified lines
3535
3636 import { Result } from "better-result";
3737 import { WorkerEntrypoint } from "cloudflare:workers";
38+import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport";
3839 import GLOBALS_MODULE from "./isolate/globals.isolate.js";
3940 import HARNESS_CODE from "./isolate/harness.isolate.js";
4041 import { authorizeRunRequest } from "./auth";
@@ -63,8 +64,9 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 22 unmodified lines
6364 }
6465
6566 /** Forward a tool call to the Convex callback RPC action. */
66− async callTool(toolPath: string, input: unknown, callId?: string): Promise<ToolCallResult> {
67− return callToolWithBridge(this.props, toolPath, input, callId);
67+ async callTool(toolPath: string, input: unknown, callId?: string): Promise<string> {
68+ const result = await callToolWithBridge(this.props, toolPath, input, callId);
69+ return encodeToolCallResultForTransport(result as ToolCallResult);
6870 }
6971 }
7072
executor/packages/runner-sandbox-host/src/isolate/harness.isolate.js+114−4
@@ -2,6 +2,111 @@ import { ResponseJson as _ResponseJson } from "./globals.js"; · 1 unmodified line
22 import { run } from "./user-code.js";
33
44 const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";
5+const TOOL_RESULT_UNDEFINED_SENTINEL = "__executor_tool_result_undefined__";
6+
7+function decodeToolBridgeResult(rawResult) {
8+ const parsed = (() => {
9+ if (typeof rawResult === "string") {
10+ try {
11+ return JSON.parse(rawResult);
12+ } catch {
13+ return null;
14+ }
15+ }
16+ if (rawResult && typeof rawResult === "object") {
17+ return rawResult;
18+ }
19+ return null;
20+ })();
21+
22+ if (!parsed || typeof parsed !== "object") {
23+ return {
24+ ok: false,
25+ kind: "failed",
26+ error: "Tool bridge returned an invalid payload",
27+ };
28+ }
29+
30+ if (parsed.ok === true) {
31+ if (typeof parsed.valueJson !== "string") {
32+ return {
33+ ok: false,
34+ kind: "failed",
35+ error: "Tool bridge success payload is missing valueJson",
36+ };
37+ }
38+
39+ if (parsed.valueJson === TOOL_RESULT_UNDEFINED_SENTINEL) {
40+ return { ok: true, value: undefined };
41+ }
42+
43+ try {
44+ return { ok: true, value: JSON.parse(parsed.valueJson) };
45+ } catch {
46+ return { ok: true, value: parsed.valueJson };
47+ }
48+ }
49+
50+ if (parsed.ok === false && parsed.kind === "pending" && typeof parsed.approvalId === "string") {
51+ return {
52+ ok: false,
53+ kind: "pending",
54+ approvalId: parsed.approvalId,
55+ ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
56+ ...(typeof parsed.retryAfterMs === "number" ? { retryAfterMs: parsed.retryAfterMs } : {}),
57+ };
58+ }
59+
60+ if (parsed.ok === false && parsed.kind === "denied" && typeof parsed.error === "string") {
61+ return {
62+ ok: false,
63+ kind: "denied",
64+ error: parsed.error,
65+ };
66+ }
67+
68+ if (parsed.ok === false && parsed.kind === "failed" && typeof parsed.error === "string") {
69+ return {
70+ ok: false,
71+ kind: "failed",
72+ error: parsed.error,
73+ };
74+ }
75+
76+ return {
77+ ok: false,
78+ kind: "failed",
79+ error: "Tool bridge returned a malformed payload",
80+ };
81+}
82+
83+function describeExecutionError(error) {
84+ if (error instanceof Error) {
85+ const message = typeof error.message === "string" ? error.message.trim() : "";
86+ if (message.length > 0) {
87+ return message;
88+ }
89+
90+ const stack = typeof error.stack === "string" ? error.stack.trim() : "";
91+ if (stack.length > 0) {
92+ return stack;
93+ }
94+
95+ const name = typeof error.name === "string" ? error.name.trim() : "";
96+ if (name.length > 0) {
97+ return name;
98+ }
99+
100+ return "Execution failed with an empty Error object";
101+ }
102+
103+ const text = String(error).trim();
104+ if (text.length > 0) {
105+ return text;
106+ }
107+
108+ return "Execution failed with an empty non-Error throw value";
109+}
5110
6111 function createToolsProxy(bridge, path = []) {
7112 const callable = () => {};
@@ -17,10 +122,16 @@ function createToolsProxy(bridge, path = []) { · 9 unmodified lines
17122 const input = args.length > 0 ? args[0] : {};
18123 const callId = "call_" + crypto.randomUUID();
19124
20− const result = await bridge.callTool(toolPath, input, callId);
125+ const result = decodeToolBridgeResult(await bridge.callTool(toolPath, input, callId));
126+
21127 if (result.ok) return result.value;
128+ if (result.kind === "pending") {
129+ throw new Error(`Tool call is unexpectedly pending approval (${result.approvalId})`);
130+ }
22131 if (result.kind === "denied") throw new Error(APPROVAL_DENIED_PREFIX + result.error);
23− throw new Error(result.error);
132+
133+ const errorText = typeof result.error === "string" ? result.error.trim() : "";
134+ throw new Error(errorText || `Tool call failed without an error message (${toolPath})`);
24135 },
25136 });
26137 }
@@ -55,8 +166,7 @@ export default { · 28 unmodified lines
55166 exitCode: 0,
56167 });
57168 } catch (error) {
58− const message =
59− error instanceof Error ? error.message : String(error);
169+ const message = describeExecutionError(error);
60170 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
61171 const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim();
62172 return _ResponseJson({
executor/packages/runner-sandbox-host/src/types.ts+2−2
@@ -45,7 +45,7 @@ export interface RunResult { · 44 unmodified lines
4545 export type ToolCallResult =
4646 | {
4747 ok: true;
48− value?: unknown;
48+ value: unknown;
4949 }
5050 | {
5151 ok: false;
@@ -57,7 +57,7 @@ export type ToolCallResult = · 5 unmodified lines
5757 | {
5858 ok: false;
5959 kind: "denied" | "failed";
60− error?: string;
60+ error: string;
6161 approvalId?: string;
6262 retryAfterMs?: number;
6363 };