- Mark non-GET/HEAD OpenAPI tools and non-query GraphQL tools as interaction: required by default - Add authorizePersistedToolInvocation that evaluates the invocation policy engine and triggers an approval elicitation when needed - Build InvocationDescriptor from OpenAPI, GraphQL, and artifact tools - Derive interaction purpose from context (tool_execution_gate, source_connect_oauth2, source_connect_secret, elicitation) - Pass accountId through workspace execution environment resolver - Add server integration test verifying default gating and policy override - Update workspace execution environment tests with onElicitation
Unified · Split
| @@ -129,6 +129,99 @@ const startOpenApiDemoServer = async () => { · 128 unmodified lines | |||
| 129 | }; | 129 | }; |
| 130 | }; | 130 | }; |
| 131 | 131 | ||
| 132 | const startMutatingOpenApiDemoServer = async () => { | ||
| 133 | const createdBodies: Array<Record<string, unknown>> = []; | ||
| 134 | const openApiDocument = JSON.stringify({ | ||
| 135 | openapi: "3.0.3", | ||
| 136 | info: { | ||
| 137 | title: "Executor DNS Demo API", | ||
| 138 | version: "1.0.0", | ||
| 139 | }, | ||
| 140 | paths: { | ||
| 141 | "/records": { | ||
| 142 | post: { | ||
| 143 | operationId: "records.createRecord", | ||
| 144 | tags: ["records"], | ||
| 145 | summary: "Create a DNS record", | ||
| 146 | requestBody: { | ||
| 147 | required: true, | ||
| 148 | content: { | ||
| 149 | "application/json": { | ||
| 150 | schema: { | ||
| 151 | type: "object", | ||
| 152 | properties: { | ||
| 153 | type: { type: "string" }, | ||
| 154 | name: { type: "string" }, | ||
| 155 | value: { type: "string" }, | ||
| 156 | }, | ||
| 157 | required: ["type", "value"], | ||
| 158 | }, | ||
| 159 | }, | ||
| 160 | }, | ||
| 161 | }, | ||
| 162 | responses: { | ||
| 163 | 200: { | ||
| 164 | description: "ok", | ||
| 165 | }, | ||
| 166 | }, | ||
| 167 | }, | ||
| 168 | }, | ||
| 169 | }, | ||
| 170 | }); | ||
| 171 | |||
| 172 | const handler = (req: IncomingMessage, res: ServerResponse) => { | ||
| 173 | if (req.url === "/openapi.json") { | ||
| 174 | res.statusCode = 200; | ||
| 175 | res.setHeader("content-type", "application/json"); | ||
| 176 | res.end(openApiDocument); | ||
| 177 | return; | ||
| 178 | } | ||
| 179 | |||
| 180 | if (req.method === "POST" && req.url === "/records") { | ||
| 181 | const chunks: Array<Buffer> = []; | ||
| 182 | req.on("data", (chunk) => { | ||
| 183 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| 184 | }); | ||
| 185 | req.on("end", () => { | ||
| 186 | const raw = Buffer.concat(chunks).toString("utf8"); | ||
| 187 | const parsed = JSON.parse(raw) as Record<string, unknown>; | ||
| 188 | createdBodies.push(parsed); | ||
| 189 | res.statusCode = 200; | ||
| 190 | res.setHeader("content-type", "application/json"); | ||
| 191 | res.end(JSON.stringify({ | ||
| 192 | ok: true, | ||
| 193 | id: `rec_${createdBodies.length}`, | ||
| 194 | record: parsed, | ||
| 195 | })); | ||
| 196 | }); | ||
| 197 | return; | ||
| 198 | } | ||
| 199 | |||
| 200 | res.statusCode = 404; | ||
| 201 | res.end("not found"); | ||
| 202 | }; | ||
| 203 | |||
| 204 | const server = createServer(handler); | ||
| 205 | await new Promise<void>((resolve, reject) => { | ||
| 206 | server.once("error", reject); | ||
| 207 | server.listen(0, "127.0.0.1", () => resolve()); | ||
| 208 | }); | ||
| 209 | const address = server.address(); | ||
| 210 | if (!address || typeof address === "string") { | ||
| 211 | throw new Error("Failed to bind mutating OpenAPI demo server"); | ||
| 212 | } | ||
| 213 | |||
| 214 | return { | ||
| 215 | baseUrl: `http://127.0.0.1:${address.port}`, | ||
| 216 | specUrl: `http://127.0.0.1:${address.port}/openapi.json`, | ||
| 217 | createdBodies, | ||
| 218 | close: () => | ||
| 219 | new Promise<void>((resolve, reject) => { | ||
| 220 | server.close((error) => (error ? reject(error) : resolve())); | ||
| 221 | }), | ||
| 222 | }; | ||
| 223 | }; | ||
| 224 | |||
| 132 | const DEMO_OAUTH_ACCESS_TOKEN = "demo-access-token"; | 225 | const DEMO_OAUTH_ACCESS_TOKEN = "demo-access-token"; |
| 133 | const DEMO_OAUTH_REFRESH_TOKEN = "demo-refresh-token"; | 226 | const DEMO_OAUTH_REFRESH_TOKEN = "demo-refresh-token"; |
| 134 | 227 | ||
| @@ -802,6 +895,107 @@ describe("local-executor-server", () => { · 667 unmodified lines | |||
| 802 | }), | 895 | }), |
| 803 | ); | 896 | ); |
| 804 | 897 | ||
| 898 | it.scoped("gates mutating OpenAPI tools by default and allows them via organization policy", () => | ||
| 899 | Effect.gen(function* () { | ||
| 900 | const openApiServer = yield* Effect.acquireRelease( | ||
| 901 | Effect.promise(() => startMutatingOpenApiDemoServer()), | ||
| 902 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), | ||
| 903 | ); | ||
| 904 | const server = yield* createLocalExecutorServer({ | ||
| 905 | port: 0, | ||
| 906 | localDataDir: ":memory:", | ||
| 907 | }); | ||
| 908 | const bootstrapClient = yield* createControlPlaneClient({ | ||
| 909 | baseUrl: server.baseUrl, | ||
| 910 | }); | ||
| 911 | const installation = yield* bootstrapClient.local.installation({}); | ||
| 912 | const client = yield* createControlPlaneClient({ | ||
| 913 | baseUrl: server.baseUrl, | ||
| 914 | accountId: installation.accountId, | ||
| 915 | }); | ||
| 916 | |||
| 917 | const connected = yield* client.sources.connect({ | ||
| 918 | path: { | ||
| 919 | workspaceId: installation.workspaceId, | ||
| 920 | }, | ||
| 921 | payload: { | ||
| 922 | kind: "openapi", | ||
| 923 | name: "DNS", | ||
| 924 | namespace: "dns", | ||
| 925 | endpoint: openApiServer.baseUrl, | ||
| 926 | specUrl: openApiServer.specUrl, | ||
| 927 | auth: { | ||
| 928 | kind: "none", | ||
| 929 | }, | ||
| 930 | }, | ||
| 931 | }); | ||
| 932 | expect(connected.kind).toBe("connected"); | ||
| 933 | |||
| 934 | const gated = yield* client.executions.create({ | ||
| 935 | path: { | ||
| 936 | workspaceId: installation.workspaceId, | ||
| 937 | }, | ||
| 938 | payload: { | ||
| 939 | code: 'return await tools.dns.records.createRecord({ body: { type: "TXT", name: "", value: "hello world" } });', | ||
| 940 | }, | ||
| 941 | }); | ||
| 942 | |||
| 943 | expect(gated.execution.status).toBe("waiting_for_interaction"); | ||
| 944 | expect(gated.pendingInteraction).not.toBeNull(); | ||
| 945 | if (gated.pendingInteraction === null) { | ||
| 946 | throw new Error("Expected pending approval interaction"); | ||
| 947 | } | ||
| 948 | expect(gated.pendingInteraction.kind).toBe("form"); | ||
| 949 | expect(gated.pendingInteraction.payloadJson).toContain("Allow POST /records?"); | ||
| 950 | expect(gated.pendingInteraction.payloadJson).toContain("\"approve\""); | ||
| 951 | |||
| 952 | const approved = yield* client.executions.resume({ | ||
| 953 | path: { | ||
| 954 | workspaceId: installation.workspaceId, | ||
| 955 | executionId: gated.execution.id, | ||
| 956 | }, | ||
| 957 | payload: { | ||
| 958 | responseJson: JSON.stringify({ | ||
| 959 | action: "accept", | ||
| 960 | content: { | ||
| 961 | approve: true, | ||
| 962 | }, | ||
| 963 | }), | ||
| 964 | }, | ||
| 965 | }); | ||
| 966 | |||
| 967 | expect(approved.execution.status).toBe("completed"); | ||
| 968 | expect(openApiServer.createdBodies).toHaveLength(1); | ||
| 969 | |||
| 970 | const policy = yield* client.policies.createOrganization({ | ||
| 971 | path: { | ||
| 972 | organizationId: installation.organizationId, | ||
| 973 | }, | ||
| 974 | payload: { | ||
| 975 | resourceType: "tool_path", | ||
| 976 | resourcePattern: "dns.records.createRecord", | ||
| 977 | matchType: "exact", | ||
| 978 | effect: "allow", | ||
| 979 | approvalMode: "auto", | ||
| 980 | }, | ||
| 981 | }); | ||
| 982 | expect(policy.scopeType).toBe("organization"); | ||
| 983 | |||
| 984 | const automatic = yield* client.executions.create({ | ||
| 985 | path: { | ||
| 986 | workspaceId: installation.workspaceId, | ||
| 987 | }, | ||
| 988 | payload: { | ||
| 989 | code: 'return await tools.dns.records.createRecord({ body: { type: "TXT", name: "", value: "hello again" } });', | ||
| 990 | }, | ||
| 991 | }); | ||
| 992 | |||
| 993 | expect(automatic.execution.status).toBe("completed"); | ||
| 994 | expect(automatic.pendingInteraction).toBeNull(); | ||
| 995 | expect(openApiServer.createdBodies).toHaveLength(2); | ||
| 996 | }), | ||
| 997 | ); | ||
| 998 | |||
| 805 | it.scoped("starts source OAuth without creating a source and stores secrets on callback", () => | 999 | it.scoped("starts source OAuth without creating a source and stores secrets on callback", () => |
| 806 | Effect.gen(function* () { | 1000 | Effect.gen(function* () { |
| 807 | const oauthServer = yield* Effect.acquireRelease( | 1001 | const oauthServer = yield* Effect.acquireRelease( |
| @@ -1074,7 +1074,7 @@ export const graphqlToolDescriptorFromDefinition = (input: { · 1073 unmodified lines | |||
| 1074 | path: asToolPath(input.path), | 1074 | path: asToolPath(input.path), |
| 1075 | sourceKey: input.sourceKey, | 1075 | sourceKey: input.sourceKey, |
| 1076 | description: input.definition.description, | 1076 | description: input.definition.description, |
| 1077 | interaction: "auto", | 1077 | interaction: input.definition.operationType === "query" ? "auto" : "required", |
| 1078 | inputType: presentation.inputType, | 1078 | inputType: presentation.inputType, |
| 1079 | outputType: presentation.outputType, | 1079 | outputType: presentation.outputType, |
| 1080 | ...(input.includeSchemas && presentation.inputSchemaJson | 1080 | ...(input.includeSchemas && presentation.inputSchemaJson |
| @@ -93,6 +93,21 @@ export const sanitizePersistedElicitationResponse = ( · 92 unmodified lines | |||
| 93 | }; | 93 | }; |
| 94 | }; | 94 | }; |
| 95 | 95 | ||
| 96 | const interactionPurposeFromInput = (input: Parameters<OnElicitation>[0]): string => { | ||
| 97 | const explicitPurpose = input.context?.interactionPurpose; | ||
| 98 | if (typeof explicitPurpose === "string" && explicitPurpose.length > 0) { | ||
| 99 | return explicitPurpose; | ||
| 100 | } | ||
| 101 | |||
| 102 | if (input.path === "executor.sources.add") { | ||
| 103 | return input.elicitation.mode === "url" | ||
| 104 | ? "source_connect_oauth2" | ||
| 105 | : "source_connect_secret"; | ||
| 106 | } | ||
| 107 | |||
| 108 | return "elicitation"; | ||
| 109 | }; | ||
| 110 | |||
| 96 | 111 | ||
| 97 | export const createLiveExecutionManager = () => { | 112 | export const createLiveExecutionManager = () => { |
| 98 | const runs = new Map<Execution["id"], LiveRunEntry>(); | 113 | const runs = new Map<Execution["id"], LiveRunEntry>(); |
| @@ -143,6 +158,7 @@ export const createLiveExecutionManager = () => { · 44 unmodified lines | |||
| 143 | executionId, | 158 | executionId, |
| 144 | status: "pending", | 159 | status: "pending", |
| 145 | kind: input.elicitation.mode === "url" ? "url" : "form", | 160 | kind: input.elicitation.mode === "url" ? "url" : "form", |
| 161 | purpose: interactionPurposeFromInput(input), | ||
| 146 | payloadJson: | 162 | payloadJson: |
| 147 | serializeJson({ | 163 | serializeJson({ |
| 148 | path: input.path, | 164 | path: input.path, |
| @@ -61,6 +61,12 @@ const decodeSourceCredentialInteraction = ( · 60 unmodified lines | |||
| 61 | interaction: ExecutionInteraction, | 61 | interaction: ExecutionInteraction, |
| 62 | ): Omit<SourceCredentialInteraction, "sourceLabel" | "endpoint"> | null => { | 62 | ): Omit<SourceCredentialInteraction, "sourceLabel" | "endpoint"> | null => { |
| 63 | try { | 63 | try { |
| 64 | if (interaction.purpose !== "source_connect_oauth2" | ||
| 65 | && interaction.purpose !== "source_connect_secret" | ||
| 66 | && interaction.purpose !== "elicitation") { | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | |||
| 64 | const payload = JSON.parse(interaction.payloadJson) as unknown; | 70 | const payload = JSON.parse(interaction.payloadJson) as unknown; |
| 65 | if (!isRecord(payload)) { | 71 | if (!isRecord(payload)) { |
| 66 | return null; | 72 | return null; |
| @@ -72,11 +78,18 @@ const decodeSourceCredentialInteraction = ( · 5 unmodified lines | |||
| 72 | return null; | 78 | return null; |
| 73 | } | 79 | } |
| 74 | 80 | ||
| 75 | if ( | 81 | if (payload.path !== "executor.sources.add" || args.kind !== "openapi") { |
| 76 | payload.path !== "executor.sources.add" | 82 | return null; |
| 77 | || args.kind !== "openapi" | 83 | } |
| 78 | || elicitation.mode !== "url" | 84 | const effectivePurpose = interaction.purpose === "elicitation" |
| 79 | ) { | 85 | ? elicitation.mode === "url" |
| 86 | ? "source_connect_oauth2" | ||
| 87 | : "source_connect_secret" | ||
| 88 | : interaction.purpose; | ||
| 89 | if (effectivePurpose === "source_connect_oauth2" && elicitation.mode !== "url") { | ||
| 90 | return null; | ||
| 91 | } | ||
| 92 | if (effectivePurpose === "source_connect_secret" && elicitation.mode !== "form") { | ||
| 80 | return null; | 93 | return null; |
| 81 | } | 94 | } |
| 82 | 95 | ||
| @@ -1229,6 +1229,10 @@ describe("workspace-execution-environment", () => { · 1228 unmodified lines | |||
| 1229 | workspaceId, | 1229 | workspaceId, |
| 1230 | accountId: AccountIdSchema.make("acc_add_graphql_fresh"), | 1230 | accountId: AccountIdSchema.make("acc_add_graphql_fresh"), |
| 1231 | executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"), | 1231 | executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"), |
| 1232 | onElicitation: () => | ||
| 1233 | Effect.succeed({ | ||
| 1234 | action: "accept" as const, | ||
| 1235 | }), | ||
| 1232 | }); | 1236 | }); |
| 1233 | 1237 | ||
| 1234 | const discovered = (yield* freshEnvironment.toolInvoker.invoke({ | 1238 | const discovered = (yield* freshEnvironment.toolInvoker.invoke({ |
| @@ -29,6 +29,7 @@ import { · 28 unmodified lines | |||
| 29 | type SqlControlPlaneRows, | 29 | type SqlControlPlaneRows, |
| 30 | } from "#persistence"; | 30 | } from "#persistence"; |
| 31 | import type { | 31 | import type { |
| 32 | AccountId, | ||
| 32 | Source, | 33 | Source, |
| 33 | StoredToolArtifactRecord, | 34 | StoredToolArtifactRecord, |
| 34 | } from "#schema"; | 35 | } from "#schema"; |
| @@ -70,6 +71,10 @@ import { · 35 unmodified lines | |||
| 70 | resolveSourceAuthMaterial, | 71 | resolveSourceAuthMaterial, |
| 71 | storedToolIdFromArtifact, | 72 | storedToolIdFromArtifact, |
| 72 | } from "./tool-artifacts"; | 73 | } from "./tool-artifacts"; |
| 74 | import { | ||
| 75 | evaluateInvocationPolicy, | ||
| 76 | type InvocationDescriptor, | ||
| 77 | } from "./invocation-policy-engine"; | ||
| 73 | 78 | ||
| 74 | const asToolPath = (value: string): ToolPath => value as ToolPath; | 79 | const asToolPath = (value: string): ToolPath => value as ToolPath; |
| 75 | 80 | ||
| @@ -382,7 +387,11 @@ const loadOpenApiWorkspaceTools = (input: { · 306 unmodified lines | |||
| 382 | path, | 387 | path, |
| 383 | sourceKey: source.id, | 388 | sourceKey: source.id, |
| 384 | description: definition.description, | 389 | description: definition.description, |
| 385 | interaction: "auto", | 390 | interaction: |
| 391 | definition.method.toUpperCase() === "GET" | ||
| 392 | || definition.method.toUpperCase() === "HEAD" | ||
| 393 | ? "auto" | ||
| 394 | : "required", | ||
| 386 | inputType: presentation.inputType, | 395 | inputType: presentation.inputType, |
| 387 | outputType: presentation.outputType, | 396 | outputType: presentation.outputType, |
| 388 | ...(input.includeSchemas && presentation.inputSchemaJson | 397 | ...(input.includeSchemas && presentation.inputSchemaJson |
| @@ -519,6 +528,185 @@ const loadSourceById = (input: { · 130 unmodified lines | |||
| 519 | ), | 528 | ), |
| 520 | ); | 529 | ); |
| 521 | 530 | ||
| 531 | const approvalSchema = { | ||
| 532 | type: "object", | ||
| 533 | properties: { | ||
| 534 | approve: { | ||
| 535 | type: "boolean", | ||
| 536 | description: "Whether to approve this tool execution", | ||
| 537 | }, | ||
| 538 | }, | ||
| 539 | required: ["approve"], | ||
| 540 | additionalProperties: false, | ||
| 541 | } satisfies Record<string, unknown>; | ||
| 542 | |||
| 543 | const approvalMessageForInvocation = (descriptor: InvocationDescriptor): string => { | ||
| 544 | if (descriptor.httpMethod && descriptor.httpPathTemplate) { | ||
| 545 | return `Allow ${descriptor.httpMethod.toUpperCase()} ${descriptor.httpPathTemplate}?`; | ||
| 546 | } | ||
| 547 | |||
| 548 | if (descriptor.graphqlOperationType) { | ||
| 549 | return `Allow GraphQL ${descriptor.graphqlOperationType} ${descriptor.toolPath}?`; | ||
| 550 | } | ||
| 551 | |||
| 552 | return `Allow tool call: ${descriptor.toolPath}?`; | ||
| 553 | }; | ||
| 554 | |||
| 555 | const toInvocationDescriptorFromOpenApiTool = (input: { | ||
| 556 | tool: OpenApiWorkspaceTool; | ||
| 557 | }): InvocationDescriptor => { | ||
| 558 | const method = input.tool.definition.method.toUpperCase(); | ||
| 559 | return { | ||
| 560 | toolPath: input.tool.path, | ||
| 561 | sourceId: input.tool.source.id, | ||
| 562 | sourceName: input.tool.source.name, | ||
| 563 | sourceKind: input.tool.source.kind, | ||
| 564 | sourceNamespace: input.tool.source.namespace ?? namespaceFromSourceName(input.tool.source.name), | ||
| 565 | operationKind: | ||
| 566 | method === "GET" || method === "HEAD" | ||
| 567 | ? "read" | ||
| 568 | : method === "DELETE" | ||
| 569 | ? "delete" | ||
| 570 | : "write", | ||
| 571 | httpMethod: method, | ||
| 572 | httpPathTemplate: input.tool.definition.path, | ||
| 573 | graphqlOperationType: null, | ||
| 574 | }; | ||
| 575 | }; | ||
| 576 | |||
| 577 | const toInvocationDescriptorFromGraphqlTool = (input: { | ||
| 578 | tool: GraphqlWorkspaceTool; | ||
| 579 | }): InvocationDescriptor => ({ | ||
| 580 | toolPath: input.tool.path, | ||
| 581 | sourceId: input.tool.source.id, | ||
| 582 | sourceName: input.tool.source.name, | ||
| 583 | sourceKind: input.tool.source.kind, | ||
| 584 | sourceNamespace: input.tool.source.namespace ?? namespaceFromSourceName(input.tool.source.name), | ||
| 585 | operationKind: | ||
| 586 | input.tool.definition.operationType === "query" | ||
| 587 | ? "read" | ||
| 588 | : input.tool.definition.operationType === "mutation" | ||
| 589 | ? "write" | ||
| 590 | : "unknown", | ||
| 591 | httpMethod: null, | ||
| 592 | httpPathTemplate: null, | ||
| 593 | graphqlOperationType: input.tool.definition.operationType, | ||
| 594 | }); | ||
| 595 | |||
| 596 | const toInvocationDescriptorFromArtifact = (input: { | ||
| 597 | toolPath: string; | ||
| 598 | source: Source; | ||
| 599 | artifact: StoredToolArtifactRecord; | ||
| 600 | }): InvocationDescriptor => ({ | ||
| 601 | toolPath: input.toolPath, | ||
| 602 | sourceId: input.source.id, | ||
| 603 | sourceName: input.source.name, | ||
| 604 | sourceKind: input.source.kind, | ||
| 605 | sourceNamespace: input.source.namespace ?? namespaceFromSourceName(input.source.name), | ||
| 606 | operationKind: "unknown", | ||
| 607 | httpMethod: input.artifact.openApiMethod?.toUpperCase() ?? null, | ||
| 608 | httpPathTemplate: input.artifact.openApiPathTemplate, | ||
| 609 | graphqlOperationType: null, | ||
| 610 | }); | ||
| 611 | |||
| 612 | const authorizePersistedToolInvocation = (input: { | ||
| 613 | rows: SqlControlPlaneRows; | ||
| 614 | workspaceId: Source["workspaceId"]; | ||
| 615 | accountId: AccountId; | ||
| 616 | descriptor: InvocationDescriptor; | ||
| 617 | args: unknown; | ||
| 618 | source: Source; | ||
| 619 | context?: Record<string, unknown>; | ||
| 620 | onElicitation?: Parameters<typeof makeToolInvokerFromTools>[0]["onElicitation"]; | ||
| 621 | }): Effect.Effect<void, Error, never> => | ||
| 622 | Effect.gen(function* () { | ||
| 623 | const workspace = yield* input.rows.workspaces.getById(input.workspaceId).pipe( | ||
| 624 | Effect.mapError((cause) => | ||
| 625 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 626 | ), | ||
| 627 | ); | ||
| 628 | const policies = Option.isSome(workspace) | ||
| 629 | ? yield* input.rows.policies.listForWorkspaceContext({ | ||
| 630 | organizationId: workspace.value.organizationId, | ||
| 631 | workspaceId: input.workspaceId, | ||
| 632 | }).pipe( | ||
| 633 | Effect.mapError((cause) => | ||
| 634 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 635 | ), | ||
| 636 | ) | ||
| 637 | : []; | ||
| 638 | |||
| 639 | const decision = evaluateInvocationPolicy({ | ||
| 640 | descriptor: input.descriptor, | ||
| 641 | args: input.args, | ||
| 642 | policies, | ||
| 643 | context: { | ||
| 644 | workspaceId: input.workspaceId, | ||
| 645 | organizationId: Option.isSome(workspace) | ||
| 646 | ? workspace.value.organizationId | ||
| 647 | : ("org_unknown" as never), | ||
| 648 | accountId: input.accountId, | ||
| 649 | clientId: | ||
| 650 | typeof input.context?.clientId === "string" | ||
| 651 | && input.context.clientId.length > 0 | ||
| 652 | ? input.context.clientId | ||
| 653 | : null, | ||
| 654 | }, | ||
| 655 | }); | ||
| 656 | |||
| 657 | if (decision.kind === "allow") { | ||
| 658 | return; | ||
| 659 | } | ||
| 660 | |||
| 661 | if (decision.kind === "deny") { | ||
| 662 | return yield* Effect.fail(new Error(decision.reason)); | ||
| 663 | } | ||
| 664 | |||
| 665 | if (!input.onElicitation) { | ||
| 666 | return yield* Effect.fail( | ||
| 667 | new Error(`Approval required for ${input.descriptor.toolPath}, but no elicitation-capable host is available`), | ||
| 668 | ); | ||
| 669 | } | ||
| 670 | |||
| 671 | const interactionId = typeof input.context?.callId === "string" && input.context.callId.length > 0 | ||
| 672 | ? `tool_execution_gate:${input.context.callId}` | ||
| 673 | : `tool_execution_gate:${crypto.randomUUID()}`; | ||
| 674 | const response = yield* input.onElicitation({ | ||
| 675 | interactionId, | ||
| 676 | path: asToolPath(input.descriptor.toolPath), | ||
| 677 | sourceKey: input.source.id, | ||
| 678 | args: input.args, | ||
| 679 | context: { | ||
| 680 | ...(input.context ?? {}), | ||
| 681 | interactionPurpose: "tool_execution_gate", | ||
| 682 | interactionReason: decision.reason, | ||
| 683 | invocationDescriptor: { | ||
| 684 | operationKind: input.descriptor.operationKind, | ||
| 685 | httpMethod: input.descriptor.httpMethod, | ||
| 686 | httpPathTemplate: input.descriptor.httpPathTemplate, | ||
| 687 | graphqlOperationType: input.descriptor.graphqlOperationType, | ||
| 688 | sourceId: input.source.id, | ||
| 689 | sourceName: input.source.name, | ||
| 690 | }, | ||
| 691 | }, | ||
| 692 | elicitation: { | ||
| 693 | mode: "form", | ||
| 694 | message: approvalMessageForInvocation(input.descriptor), | ||
| 695 | requestedSchema: approvalSchema, | ||
| 696 | }, | ||
| 697 | }).pipe( | ||
| 698 | Effect.mapError((cause) => | ||
| 699 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 700 | ), | ||
| 701 | ); | ||
| 702 | |||
| 703 | if (response.action !== "accept") { | ||
| 704 | return yield* Effect.fail( | ||
| 705 | new Error(`Tool invocation not approved for ${input.descriptor.toolPath}`), | ||
| 706 | ); | ||
| 707 | } | ||
| 708 | }); | ||
| 709 | |||
| 522 | const createWorkspaceToolCatalog = (input: { | 710 | const createWorkspaceToolCatalog = (input: { |
| 523 | workspaceId: Source["workspaceId"]; | 711 | workspaceId: Source["workspaceId"]; |
| 524 | rows: SqlControlPlaneRows; | 712 | rows: SqlControlPlaneRows; |
| @@ -780,6 +968,7 @@ const createWorkspaceToolCatalog = (input: { · 255 unmodified lines | |||
| 780 | 968 | ||
| 781 | const createWorkspaceToolInvoker = (input: { | 969 | const createWorkspaceToolInvoker = (input: { |
| 782 | workspaceId: Source["workspaceId"]; | 970 | workspaceId: Source["workspaceId"]; |
| 971 | accountId: AccountId; | ||
| 783 | rows: SqlControlPlaneRows; | 972 | rows: SqlControlPlaneRows; |
| 784 | resolveSecretMaterial: ResolveSecretMaterial; | 973 | resolveSecretMaterial: ResolveSecretMaterial; |
| 785 | sourceAuthService: RuntimeSourceAuthService; | 974 | sourceAuthService: RuntimeSourceAuthService; |
| @@ -825,6 +1014,17 @@ const createWorkspaceToolInvoker = (input: { · 39 unmodified lines | |||
| 825 | }); | 1014 | }); |
| 826 | const openApiTool = openApiTools.find((tool) => tool.path === invocation.path); | 1015 | const openApiTool = openApiTools.find((tool) => tool.path === invocation.path); |
| 827 | if (openApiTool) { | 1016 | if (openApiTool) { |
| 1017 | yield* authorizePersistedToolInvocation({ | ||
| 1018 | rows: input.rows, | ||
| 1019 | workspaceId: input.workspaceId, | ||
| 1020 | accountId: input.accountId, | ||
| 1021 | descriptor: toInvocationDescriptorFromOpenApiTool({ tool: openApiTool }), | ||
| 1022 | args: invocation.args, | ||
| 1023 | source: openApiTool.source, | ||
| 1024 | context: invocation.context, | ||
| 1025 | onElicitation: input.onElicitation, | ||
| 1026 | }); | ||
| 1027 | |||
| 828 | const auth = yield* resolveSourceAuthMaterial({ | 1028 | const auth = yield* resolveSourceAuthMaterial({ |
| 829 | source: openApiTool.source, | 1029 | source: openApiTool.source, |
| 830 | resolveSecretMaterial: input.resolveSecretMaterial, | 1030 | resolveSecretMaterial: input.resolveSecretMaterial, |
| @@ -857,6 +1057,17 @@ const createWorkspaceToolInvoker = (input: { · 26 unmodified lines | |||
| 857 | }); | 1057 | }); |
| 858 | const graphqlTool = graphqlTools.find((tool) => tool.path === invocation.path); | 1058 | const graphqlTool = graphqlTools.find((tool) => tool.path === invocation.path); |
| 859 | if (graphqlTool) { | 1059 | if (graphqlTool) { |
| 1060 | yield* authorizePersistedToolInvocation({ | ||
| 1061 | rows: input.rows, | ||
| 1062 | workspaceId: input.workspaceId, | ||
| 1063 | accountId: input.accountId, | ||
| 1064 | descriptor: toInvocationDescriptorFromGraphqlTool({ tool: graphqlTool }), | ||
| 1065 | args: invocation.args, | ||
| 1066 | source: graphqlTool.source, | ||
| 1067 | context: invocation.context, | ||
| 1068 | onElicitation: input.onElicitation, | ||
| 1069 | }); | ||
| 1070 | |||
| 860 | const auth = yield* resolveSourceAuthMaterial({ | 1071 | const auth = yield* resolveSourceAuthMaterial({ |
| 861 | source: graphqlTool.source, | 1072 | source: graphqlTool.source, |
| 862 | resolveSecretMaterial: input.resolveSecretMaterial, | 1073 | resolveSecretMaterial: input.resolveSecretMaterial, |
| @@ -907,6 +1118,21 @@ const createWorkspaceToolInvoker = (input: { · 44 unmodified lines | |||
| 907 | ); | 1118 | ); |
| 908 | } | 1119 | } |
| 909 | 1120 | ||
| 1121 | yield* authorizePersistedToolInvocation({ | ||
| 1122 | rows: input.rows, | ||
| 1123 | workspaceId: input.workspaceId, | ||
| 1124 | accountId: input.accountId, | ||
| 1125 | descriptor: toInvocationDescriptorFromArtifact({ | ||
| 1126 | toolPath: invocation.path, | ||
| 1127 | source, | ||
| 1128 | artifact, | ||
| 1129 | }), | ||
| 1130 | args: invocation.args, | ||
| 1131 | source, | ||
| 1132 | context: invocation.context, | ||
| 1133 | onElicitation: input.onElicitation, | ||
| 1134 | }); | ||
| 1135 | |||
| 910 | const auth = yield* resolveSourceAuthMaterial({ | 1136 | const auth = yield* resolveSourceAuthMaterial({ |
| 911 | source, | 1137 | source, |
| 912 | resolveSecretMaterial: input.resolveSecretMaterial, | 1138 | resolveSecretMaterial: input.resolveSecretMaterial, |
| @@ -977,10 +1203,11 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 64 unmodified lines | |||
| 977 | rows: input.rows, | 1203 | rows: input.rows, |
| 978 | }); | 1204 | }); |
| 979 | 1205 | ||
| 980 | return ({ workspaceId, onElicitation }) => | 1206 | return ({ workspaceId, accountId, onElicitation }) => |
| 981 | Effect.sync(() => { | 1207 | Effect.sync(() => { |
| 982 | const { catalog, toolInvoker } = createWorkspaceToolInvoker({ | 1208 | const { catalog, toolInvoker } = createWorkspaceToolInvoker({ |
| 983 | workspaceId, | 1209 | workspaceId, |
| 1210 | accountId, | ||
| 984 | rows: input.rows, | 1211 | rows: input.rows, |
| 985 | resolveSecretMaterial, | 1212 | resolveSecretMaterial, |
| 986 | sourceAuthService: input.sourceAuthService, | 1213 | sourceAuthService: input.sourceAuthService, |