History

update executor tools

Route confidential OAuth-app secrets through a browser handoff instead of the agent context, and approval-gate connection/oauth/policy writes.

Rhys Sullivan committed 4 months ago

Commit 2bca9431 parent 10858448 files · +853 −13Browse files

Unified · Split

8 files
  1. e2e/scenarios/connect-handoff.test.ts +9 −5
  2. e2e/scenarios/no-auth-connection.test.ts +4 −1
  3. e2e/scenarios/oauth-client-handoff.test.ts +478 −0
  4. e2e/scenarios/policy-tool-approval.test.ts +144 −0
  5. packages/core/sdk/src/client.ts +13 −0
  6. packages/core/sdk/src/core-tools.ts +130 −3
  7. packages/react/src/components/add-account-modal.tsx +54 −4
  8. packages/react/src/pages/integration-detail.tsx +21 −0
e2e/scenarios/connect-handoff.test.ts+9−5
@@ -89,13 +89,15 @@ const sent = await t({ · 88 unmodified lines
89return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };89return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };
90`;90`;
91 91
92/** Run `execute`, auto-approving a paused execution (policy elicitation) once,92/** Run `execute`, auto-approving any paused executions (approval-gated tools
93 * and parse the sandbox's JSON return value. */93 * pause once per gated call) and parse the sandbox's JSON return value. */
94const executeJson = (session: McpSession, code: string) =>94const executeJson = (session: McpSession, code: string) =>
95 Effect.gen(function* () {95 Effect.gen(function* () {
96 let result = yield* session.call("execute", { code });96 let result = yield* session.call("execute", { code });
97 if (result.text.includes("executionId:")) {97 let guard = 0;
98 while (result.text.includes("executionId:") && guard < 10) {
98 result = yield* session.approvePaused(result.text);99 result = yield* session.approvePaused(result.text);
100 guard += 1;
99 }101 }
100 expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);102 expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
101 return JSON.parse(result.text) as Record<string, unknown>;103 return JSON.parse(result.text) as Record<string, unknown>;
@@ -148,10 +150,12 @@ scenario( · 46 unmodified lines
148 orgSlug: orgSlug!,150 orgSlug: orgSlug!,
149 }).pipe(151 }).pipe(
150 // Best-effort cleanup even on failure: drop the created connection(s)152 // Best-effort cleanup even on failure: drop the created connection(s)
151 // over MCP, then the integration over the API.153 // over MCP, then the integration over the API. `connections.remove` is
154 // approval-gated, so the cleanup execute pauses per connection;
155 // `executeJson` auto-approves each pause so the removes actually run.
152 Effect.ensuring(156 Effect.ensuring(
153 Effect.gen(function* () {157 Effect.gen(function* () {
154 yield* session.call("execute", { code: removeConnectionsCode(integration) });158 yield* executeJson(session, removeConnectionsCode(integration));
155 yield* client.openapi.removeSpec({ params: { slug: integration } });159 yield* client.openapi.removeSpec({ params: { slug: integration } });
156 }).pipe(Effect.ignore),160 }).pipe(Effect.ignore),
157 ),161 ),
e2e/scenarios/no-auth-connection.test.ts+4−1
@@ -192,9 +192,12 @@ scenario( · 191 unmodified lines
192 }).pipe(192 }).pipe(
193 // Selfhost shares one workspace identity — leaked connections fail other193 // Selfhost shares one workspace identity — leaked connections fail other
194 // scenarios' zero-state assertions, so drop everything this run made.194 // scenarios' zero-state assertions, so drop everything this run made.
195 // `connections.remove` is approval-gated, so the cleanup execute pauses
196 // per connection; `executeJson` auto-approves each pause so the removes
197 // actually run.
195 Effect.ensuring(198 Effect.ensuring(
196 Effect.gen(function* () {199 Effect.gen(function* () {
197 yield* session.call("execute", { code: removeConnectionsCode(integration) });200 yield* executeJson(session, removeConnectionsCode(integration));
198 yield* client.openapi.removeSpec({ params: { slug: integration } });201 yield* client.openapi.removeSpec({ params: { slug: integration } });
199 }).pipe(Effect.ignore),202 }).pipe(Effect.ignore),
200 ),203 ),
e2e/scenarios/oauth-client-handoff.test.ts+478−0
@@ -0,0 +1,478 @@
1// Secrets never cross the agent boundary: a confidential OAuth app is registered
2// through a BROWSER handoff, not by passing the client secret as a tool argument.
3//
4// This is the regression guard for the secret-leak fix. The agent-facing
5// `oauth.clients.create` used to take a `clientSecret`, which meant a confidential
6// app's secret flowed through the LLM context window. That field is gone; the
7// agent now calls `oauth.clients.createHandoff`, which returns a deep link into
8// the web UI where the HUMAN types the secret, exactly like a pasted connection
9// credential (`connections.createHandoff`).
10//
11// Three scenarios:
12// 1. `createHandoff` returns a correct, secret-free deep link and is NOT
13// approval-gated (it is the safe path). A smuggled `clientSecret` argument
14// never appears in the URL.
15// 2. `oauth.clients.create` still pauses for human approval (the write gate
16// survived the secret removal) and writes nothing when declined.
17// 3. Watch-it: the agent hands off, a human opens the URL and types the secret
18// into the Register-OAuth-app form (auto-opened and pre-filled from the
19// handoff), the app registers, and the agent completes a client-credentials
20// connection against the Microsoft emulator without ever seeing the secret.
21import { randomBytes } from "node:crypto";
22
23import { expect } from "@effect/vitest";
24import { Effect } from "effect";
25import { AccountHttpApi } from "@executor-js/api";
26import { composePluginApi } from "@executor-js/api/server";
27import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate";
28import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";
29import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
30import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
31
32import { scenario } from "../src/scenario";
33import { Api, Browser, Mcp, Target } from "../src/services";
34import type { McpSession } from "../src/surfaces/mcp";
35
36const microsoftApi = composePluginApi([microsoftHttpPlugin()] as const);
37
38const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
39
40// A sentinel the agent tries to smuggle as a `clientSecret` argument the handoff
41// tool does not declare. The secret must never reach the returned URL (nor any
42// part of the agent-visible response), proving the boundary drops it.
43const SMUGGLED_SECRET = "SMUGGLED_SECRET_DO_NOT_LEAK_4f1a9c";
44
45/** Run `execute`, auto-approving any paused (approval-gated) calls, and parse the
46 * sandbox's JSON return value. Mirrors the connect-handoff helper. */
47const executeJson = (session: McpSession, code: string) =>
48 Effect.gen(function* () {
49 let result = yield* session.call("execute", { code });
50 let guard = 0;
51 while (result.text.includes("executionId:") && guard < 10) {
52 result = yield* session.approvePaused(result.text);
53 guard += 1;
54 }
55 expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
56 return JSON.parse(result.text) as Record<string, unknown>;
57 });
58
59// ---------------------------------------------------------------------------
60// 1. The handoff URL: secret-free, correctly addressed, and not approval-gated.
61// ---------------------------------------------------------------------------
62
63const createHandoffCode = (input: {
64 readonly integration: string;
65 readonly slug: string;
66 readonly clientId: string;
67 readonly authorizationUrl: string;
68 readonly tokenUrl: string;
69}) => `
70const handoff = await tools.executor.coreTools.oauth.clients.createHandoff({
71 integration: ${JSON.stringify(input.integration)},
72 owner: "user",
73 slug: ${JSON.stringify(input.slug)},
74 grant: "client_credentials",
75 clientId: ${JSON.stringify(input.clientId)},
76 authorizationUrl: ${JSON.stringify(input.authorizationUrl)},
77 tokenUrl: ${JSON.stringify(input.tokenUrl)},
78});
79return handoff.ok
80 ? { ok: true, url: handoff.data.url, instructions: handoff.data.instructions }
81 : { ok: false, error: handoff.error };
82`;
83
84// The same call, but with a `clientSecret` the tool does not declare. Whatever
85// the boundary does with the excess field (drop or reject), the secret VALUE
86// must not appear anywhere in the agent-visible result.
87const smuggleSecretCode = (input: {
88 readonly integration: string;
89 readonly slug: string;
90 readonly clientId: string;
91 readonly tokenUrl: string;
92}) => `
93const handoff = await tools.executor.coreTools.oauth.clients.createHandoff({
94 integration: ${JSON.stringify(input.integration)},
95 slug: ${JSON.stringify(input.slug)},
96 grant: "client_credentials",
97 clientId: ${JSON.stringify(input.clientId)},
98 tokenUrl: ${JSON.stringify(input.tokenUrl)},
99 clientSecret: ${JSON.stringify(SMUGGLED_SECRET)},
100});
101return JSON.stringify(handoff);
102`;
103
104scenario(
105 "OAuth client · createHandoff returns a secret-free deep link and is not approval-gated",
106 {},
107 Effect.gen(function* () {
108 const target = yield* Target;
109 const { client: makeApiClient } = yield* Api;
110 const mcp = yield* Mcp;
111 const identity = yield* target.newIdentity();
112 const session = mcp.session(identity);
113
114 // The bound org's slug, read from the same surface the console shell reads —
115 // the handoff URL must canonicalize onto exactly this org.
116 const accountClient = yield* makeApiClient(AccountHttpApi, identity);
117 const me = yield* accountClient.account.me();
118 const orgSlug = me.organization?.slug;
119 expect(orgSlug, "the bound organization advertises a URL slug").toBeTruthy();
120
121 const integration = unique("oauthhf");
122 const clientSlug = unique("app");
123 const clientId = unique("client-id");
124 const authorizationUrl = "https://issuer.example.com/oauth2/v2.0/authorize";
125 const tokenUrl = "https://issuer.example.com/oauth2/v2.0/token";
126
127 // createHandoff is a pure URL builder — it must NOT pause for approval (it is
128 // the safe path that routes the secret to the human, mirroring
129 // `connections.createHandoff`).
130 const raw = yield* session.call("execute", {
131 code: createHandoffCode({
132 integration,
133 slug: clientSlug,
134 clientId,
135 authorizationUrl,
136 tokenUrl,
137 }),
138 });
139 expect(raw.text, "createHandoff is not approval-gated (no pause)").not.toContain(
140 "executionId:",
141 );
142 expect(raw.ok, `createHandoff execute completed (got: ${raw.text.slice(0, 400)})`).toBe(true);
143 const handoff = JSON.parse(raw.text) as { ok: boolean; url?: string; instructions?: string };
144 expect(handoff.ok, `createHandoff succeeded: ${raw.text.slice(0, 400)}`).toBe(true);
145
146 const url = new URL(String(handoff.url));
147 expect(url.origin, `handoff URL (${handoff.url}) targets this deployment`).toBe(
148 new URL(target.baseUrl).origin,
149 );
150 expect(url.pathname, `handoff URL (${handoff.url}) carries the bound org slug`).toBe(
151 `/${orgSlug}/integrations/${integration}`,
152 );
153 // The flags that flip the integration's Add-account flow into the
154 // Register-OAuth-app form, pre-filled with the agent's non-secret fields.
155 expect(url.searchParams.get("addAccount")).toBe("1");
156 expect(url.searchParams.get("oauthClient")).toBe("1");
157 expect(url.searchParams.get("owner")).toBe("user");
158 expect(url.searchParams.get("clientSlug")).toBe(clientSlug);
159 expect(url.searchParams.get("grant")).toBe("client_credentials");
160 expect(url.searchParams.get("clientId")).toBe(clientId);
161 expect(url.searchParams.get("authorizationUrl")).toBe(authorizationUrl);
162 expect(url.searchParams.get("tokenUrl")).toBe(tokenUrl);
163
164 // No secret leaves the host: there is no secret-bearing field on the tool,
165 // so the URL carries none.
166 expect(url.searchParams.get("clientSecret"), "no clientSecret param").toBeNull();
167 expect(url.searchParams.get("secret"), "no secret param").toBeNull();
168
169 // The instructions steer the agent away from ever asking for the secret.
170 expect(String(handoff.instructions)).toMatch(/client secret/i);
171 expect(String(handoff.instructions)).toMatch(/chat/i);
172
173 // Adversarial: an agent that tries to smuggle a secret as an undeclared
174 // argument gets it dropped — the value never appears in the response.
175 const smuggled = yield* session.call("execute", {
176 code: smuggleSecretCode({ integration, slug: clientSlug, clientId, tokenUrl }),
177 });
178 expect(
179 smuggled.text.includes(SMUGGLED_SECRET),
180 "a smuggled client secret never reaches the agent-visible handoff result",
181 ).toBe(false);
182 }),
183);
184
185// ---------------------------------------------------------------------------
186// 2. The write path stays gated: `oauth.clients.create` (now secret-free) still
187// pauses for human approval, and a decline writes nothing.
188// ---------------------------------------------------------------------------
189
190const createPublicClientCode = (slug: string) => `
191const result = await tools.executor.coreTools.oauth.clients.create({
192 owner: "user",
193 slug: ${JSON.stringify(slug)},
194 authorizationUrl: "https://issuer.example.com/authorize",
195 tokenUrl: "https://issuer.example.com/token",
196 grant: "authorization_code",
197 clientId: ${JSON.stringify(unique("public-client"))},
198});
199return JSON.stringify(result);
200`;
201
202const listClientsCode = `
203const result = await tools.executor.coreTools.oauth.clients.list({});
204return JSON.stringify(result.ok ? result.data.clients.map((c) => c.slug) : { error: result.error });
205`;
206
207scenario(
208 "OAuth client · oauth.clients.create still pauses for human approval and a decline writes nothing",
209 {},
210 Effect.gen(function* () {
211 const target = yield* Target;
212 const mcp = yield* Mcp;
213 const identity = yield* target.newIdentity();
214 const session = mcp.session(identity);
215 const slug = unique("gated-app");
216
217 yield* session.listTools();
218
219 // Nothing has registered a policy here — the only thing that can pause this
220 // call is the tool's own `requiresApproval` annotation, which must have
221 // survived dropping the client secret from the input.
222 const paused = yield* session.call("execute", { code: createPublicClientCode(slug) });
223 expect(paused.text, "oauth.clients.create pauses for approval (annotation guard)").toContain(
224 "Execution paused",
225 );
226 expect(paused.text, "paused result carries the executionId").toContain("executionId:");
227
228 // Decline: the client must not be written.
229 const match = /\bexecutionId:\s*(\S+)/.exec(paused.text);
230 expect(match, "paused result carries an executionId to resume").not.toBeNull();
231 yield* session.call("resume", { executionId: match![1], action: "decline" });
232
233 const slugs = JSON.parse((yield* session.call("execute", { code: listClientsCode })).text) as
234 | ReadonlyArray<string>
235 | { error: unknown };
236 expect(
237 Array.isArray(slugs) && slugs.includes(slug),
238 "a declined oauth.clients.create never registered the client",
239 ).toBe(false);
240 }),
241);
242
243// ---------------------------------------------------------------------------
244// 3. Watch-it: the whole secret-free path, end to end, against the hosted
245// Microsoft emulator. The agent hands off; the human types the secret in the
246// browser; the agent connects.
247// ---------------------------------------------------------------------------
248
249const EMULATOR_BASE = "https://microsoft.emulators.dev";
250
251const handoffForBrowserCode = (input: {
252 readonly integration: string;
253 readonly slug: string;
254 readonly clientId: string;
255 readonly authorizationUrl: string;
256 readonly tokenUrl: string;
257}) => `
258const handoff = await tools.executor.coreTools.oauth.clients.createHandoff({
259 integration: ${JSON.stringify(input.integration)},
260 owner: "org",
261 slug: ${JSON.stringify(input.slug)},
262 grant: "client_credentials",
263 clientId: ${JSON.stringify(input.clientId)},
264 authorizationUrl: ${JSON.stringify(input.authorizationUrl)},
265 tokenUrl: ${JSON.stringify(input.tokenUrl)},
266 label: "Microsoft Graph (emulated)",
267});
268return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error };
269`;
270
271const listClientSlugsCode = `
272const result = await tools.executor.coreTools.oauth.clients.list({});
273return result.ok ? { ok: true, slugs: result.data.clients.map((c) => c.slug) } : { ok: false, error: result.error };
274`;
275
276const startConnectionCode = (input: {
277 readonly slug: string;
278 readonly integration: string;
279 readonly connection: string;
280 readonly template: string;
281}) => `
282const started = await tools.executor.coreTools.oauth.start({
283 client: ${JSON.stringify(input.slug)},
284 clientOwner: "org",
285 owner: "org",
286 name: ${JSON.stringify(input.connection)},
287 integration: ${JSON.stringify(input.integration)},
288 template: ${JSON.stringify(input.template)},
289});
290return started.ok ? { ok: true, status: started.data.status } : { ok: false, error: started.error };
291`;
292
293const requireOAuthClientCredential = (credential: IssuedCredential) =>
294 Effect.gen(function* () {
295 if (
296 credential.client_id &&
297 credential.client_secret &&
298 credential.authorization_url &&
299 credential.token_url
300 ) {
301 return {
302 clientId: credential.client_id,
303 clientSecret: credential.client_secret,
304 authorizationUrl: credential.authorization_url,
305 tokenUrl: credential.token_url,
306 };
307 }
308 return yield* Effect.die("Microsoft emulator returned incomplete OAuth client credentials.");
309 });
310
311scenario(
312 "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects",
313 { timeout: 240_000 },
314 Effect.gen(function* () {
315 const target = yield* Target;
316 const { client: makeApiClient } = yield* Api;
317 const mcp = yield* Mcp;
318 const browser = yield* Browser;
319 const identity = yield* target.newIdentity();
320 const session = mcp.session(identity);
321 const client = yield* makeApiClient(microsoftApi, identity);
322
323 const accountClient = yield* makeApiClient(AccountHttpApi, identity);
324 const me = yield* accountClient.account.me();
325 const orgSlug = me.organization?.slug;
326 expect(orgSlug, "the bound organization advertises a URL slug").toBeTruthy();
327
328 const integration = unique("msgraph");
329 const clientSlug = unique("msgraph_app");
330 const connection = "machine";
331 const template = MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG;
332
333 // The hosted emulator mints a real-shaped client-credentials app and records
334 // every token exchange — the ledger is shared, so key everything off the
335 // unique minted clientId.
336 const emulator: EmulatorClient = yield* Effect.promise(() =>
337 connectEmulator({ baseUrl: EMULATOR_BASE, service: "microsoft" }),
338 );
339 const minted = yield* Effect.promise(() =>
340 emulator.credentials.mint({ type: "oauth-client-credentials", name: "Executor E2E Graph" }),
341 );
342 const oauth = yield* requireOAuthClientCredential(minted);
343
344 yield* Effect.ensuring(
345 Effect.gen(function* () {
346 // Register the Microsoft Graph integration so the console has an OAuth
347 // method to register a client against.
348 yield* client.microsoft.addGraph({
349 payload: {
350 presetIds: ["users"],
351 customScopes: [],
352 slug: integration,
353 name: "Microsoft Graph Emulator",
354 baseUrl: emulator.baseUrl,
355 specUrl: emulator.openapiUrl,
356 },
357 });
358
359 // 1. The agent asks for a browser handoff URL — it has the client id and
360 // endpoints (discovered/known), but never the secret.
361 const handoff = yield* executeJson(
362 session,
363 handoffForBrowserCode({
364 integration,
365 slug: clientSlug,
366 clientId: oauth.clientId,
367 authorizationUrl: oauth.authorizationUrl,
368 tokenUrl: oauth.tokenUrl,
369 }),
370 );
371 expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
372 const handoffUrl = String(handoff.url);
373
374 const parsed = new URL(handoffUrl);
375 expect(parsed.origin, `handoff URL (${handoffUrl}) targets this deployment`).toBe(
376 new URL(target.baseUrl).origin,
377 );
378 expect(parsed.pathname).toBe(`/${orgSlug}/integrations/${integration}`);
379 // The agent's URL carries the client id but NOT the secret.
380 expect(handoffUrl).toContain(oauth.clientId);
381 expect(
382 handoffUrl.includes(oauth.clientSecret),
383 "the handoff URL never carries the client secret",
384 ).toBe(false);
385
386 // 2. The human opens the URL: the Register-OAuth-app form is open and
387 // pre-filled from the handoff. They type ONLY the secret.
388 yield* browser.session(identity, async ({ page, step }) => {
389 await step("Open the agent's handoff URL", async () => {
390 await page.goto(handoffUrl, { waitUntil: "networkidle" });
391 });
392
393 await step("The Register-OAuth-app form auto-opens, pre-filled", async () => {
394 await page
395 .getByRole("heading", { name: "Register OAuth app" })
396 .waitFor({ timeout: 20_000 });
397 // The agent's non-secret fields pre-filled — this is the whole point
398 // of the handoff: the human verifies, they don't re-type.
399 await expect
400 .poll(() => page.locator("#oauth-client-id").inputValue())
401 .toBe(oauth.clientId);
402 await expect
403 .poll(() => page.locator("#grant-client_credentials").isChecked())
404 .toBe(true);
405 });
406
407 await step("The human types the client secret (only the secret)", async () => {
408 const secret = page.locator("#oauth-client-secret");
409 await secret.waitFor({ timeout: 15_000 });
410 await secret.fill(oauth.clientSecret);
411 });
412
413 await step("Register the app", async () => {
414 await page.getByRole("button", { name: "Register app" }).click();
415 // onCreated returns to the Add-connection view — the register form closes.
416 await page
417 .getByRole("heading", { name: "Register OAuth app" })
418 .waitFor({ state: "hidden", timeout: 20_000 });
419 });
420 });
421
422 // 3. The agent discovers the browser-registered client and completes the
423 // connection — client credentials need no user consent.
424 const listed = yield* executeJson(session, listClientSlugsCode);
425 expect(
426 (listed.slugs as ReadonlyArray<string> | undefined)?.includes(clientSlug),
427 `the agent sees the human-registered client: ${JSON.stringify(listed)}`,
428 ).toBe(true);
429
430 const started = yield* executeJson(
431 session,
432 startConnectionCode({
433 slug: clientSlug,
434 integration,
435 connection,
436 template: String(template),
437 }),
438 );
439 expect(started.ok, `oauth.start succeeded: ${JSON.stringify(started)}`).toBe(true);
440 expect(started.status, "client-credentials OAuth connected without browser consent").toBe(
441 "connected",
442 );
443
444 // 4. The emulator ledger proves Executor exchanged THIS app's credentials.
445 const ledger = yield* Effect.promise(() => emulator.ledger.list());
446 const tokenRequest = ledger.find(
447 (entry) =>
448 entry.path === "/oauth2/v2.0/token" &&
449 JSON.stringify(entry.request.body ?? "").includes(oauth.clientId),
450 );
451 expect(
452 tokenRequest?.response.status,
453 "the emulator recorded a client-credentials token exchange for this app",
454 ).toBe(200);
455 expect(tokenRequest?.request.body).toMatchObject({ grant_type: "client_credentials" });
456 }),
457 // Best-effort teardown: selfhost shares one workspace, so remove everything.
458 Effect.gen(function* () {
459 yield* client.connections
460 .remove({
461 params: {
462 owner: "org",
463 integration: IntegrationSlug.make(integration),
464 name: ConnectionName.make(connection),
465 },
466 })
467 .pipe(Effect.ignore);
468 yield* client.oauth
469 .removeClient({
470 params: { slug: OAuthClientSlug.make(clientSlug) },
471 payload: { owner: "org" },
472 })
473 .pipe(Effect.ignore);
474 yield* client.microsoft.removeGraph({ params: { slug: integration } }).pipe(Effect.ignore);
475 }).pipe(Effect.ignore),
476 );
477 }),
478);
e2e/scenarios/policy-tool-approval.test.ts+144−0
@@ -0,0 +1,144 @@
1// Cross-target: the policy-mutation core tools gate themselves on human
2// approval via their OWN `requiresApproval` annotation, with no policy in play.
3//
4// This is the regression guard for the approval-bypass finding: a prior rewrite
5// of `coreTools` dropped the `requiresApproval` annotations from the policy
6// tools, so prompt-injected sandbox code could call
7// `tools.executor.coreTools.policies.create({ pattern: "*", action: "approve" })`
8// and silently disable every other approval gate. Here the sandbox calls
9// `policies.create` directly with NO matching policy present, so the only thing
10// that can pause the execution is the tool's annotation. If the annotation is
11// missing the call runs silently and the "Execution paused" assertion fails.
12//
13// Runs over MCP in model-managed elicitation mode (no browser): a gated call
14// returns a paused result carrying an `executionId`; `resume` accepts or
15// declines it. The created policy is a `block` rule on a unique, non-matching
16// pattern, so even a leak cannot gate another scenario's tools; it is removed in
17// an `ensuring` finalizer regardless.
18import { randomUUID } from "node:crypto";
19
20import { expect } from "@effect/vitest";
21import { Effect } from "effect";
22import { composePluginApi } from "@executor-js/api/server";
23
24import { scenario } from "../src/scenario";
25import { Api, Mcp, Target } from "../src/services";
26
27const coreApi = composePluginApi([] as const);
28
29/** Sandbox code that creates a policy through the gated core tool. The pattern
30 * is unique-per-run and matches no real tool, so the rule is inert. */
31const createPolicyCode = (pattern: string) => `
32const result = await tools.executor.coreTools.policies.create({
33 owner: "user",
34 pattern: ${JSON.stringify(pattern)},
35 action: "block",
36});
37return JSON.stringify(result);
38`;
39
40scenario(
41 "Policy tools · policies.create pauses for approval from its own annotation, then runs once approved",
42 {},
43 Effect.gen(function* () {
44 const target = yield* Target;
45 const apiSurface = yield* Api;
46 const mcp = yield* Mcp;
47 const identity = yield* target.newIdentity();
48 const client = yield* apiSurface.client(coreApi, identity);
49 const pattern = `policy-guard-approve-${randomUUID().slice(0, 8)}.*`;
50
51 // Best-effort removal of any policy left under `pattern` for this identity.
52 const cleanup = client.policies.list().pipe(
53 Effect.flatMap((list) =>
54 Effect.forEach(
55 list.filter((p) => p.pattern === pattern),
56 (p) =>
57 client.policies
58 .remove({ params: { policyId: p.id }, payload: { owner: "user" } })
59 .pipe(Effect.ignore),
60 ),
61 ),
62 Effect.ignore,
63 );
64
65 yield* Effect.gen(function* () {
66 const session = mcp.session(identity);
67
68 // Warm the OAuth handshake before the gated call.
69 const tools = yield* session.listTools();
70 expect(tools).toContain("execute");
71
72 // No policy exists for `policies.create` here — the only thing that can
73 // pause this call is the tool's `requiresApproval` annotation.
74 const paused = yield* session.call("execute", { code: createPolicyCode(pattern) });
75 expect(
76 paused.text,
77 "policies.create paused for approval with no policy present (annotation guard)",
78 ).toContain("Execution paused");
79 expect(paused.text, "paused result carries the executionId").toContain("executionId:");
80
81 // The policy must not exist until the human approves.
82 const beforeApproval = yield* client.policies.list();
83 expect(
84 beforeApproval.some((p) => p.pattern === pattern),
85 "policy is not written while the approval is still pending",
86 ).toBe(false);
87
88 const resumed = yield* session.approvePaused(paused.text);
89 expect(resumed.ok, "resumed execution completed without error").toBe(true);
90
91 const afterApproval = yield* client.policies.list();
92 expect(
93 afterApproval.some((p) => p.pattern === pattern),
94 "policy is written only after the human approves",
95 ).toBe(true);
96 }).pipe(Effect.ensuring(cleanup));
97 }),
98);
99
100scenario(
101 "Policy tools · declining the approval blocks policies.create",
102 {},
103 Effect.gen(function* () {
104 const target = yield* Target;
105 const apiSurface = yield* Api;
106 const mcp = yield* Mcp;
107 const identity = yield* target.newIdentity();
108 const client = yield* apiSurface.client(coreApi, identity);
109 const pattern = `policy-guard-decline-${randomUUID().slice(0, 8)}.*`;
110
111 const cleanup = client.policies.list().pipe(
112 Effect.flatMap((list) =>
113 Effect.forEach(
114 list.filter((p) => p.pattern === pattern),
115 (p) =>
116 client.policies
117 .remove({ params: { policyId: p.id }, payload: { owner: "user" } })
118 .pipe(Effect.ignore),
119 ),
120 ),
121 Effect.ignore,
122 );
123
124 yield* Effect.gen(function* () {
125 const session = mcp.session(identity);
126 yield* session.listTools();
127
128 const paused = yield* session.call("execute", { code: createPolicyCode(pattern) });
129 expect(paused.text, "policies.create paused for approval (annotation guard)").toContain(
130 "Execution paused",
131 );
132
133 const match = /\bexecutionId:\s*(\S+)/.exec(paused.text);
134 expect(match, "paused result carries an executionId to resume").not.toBeNull();
135 yield* session.call("resume", { executionId: match![1], action: "decline" });
136
137 const list = yield* client.policies.list();
138 expect(
139 list.some((p) => p.pattern === pattern),
140 "declined policies.create never wrote the policy",
141 ).toBe(false);
142 }).pipe(Effect.ensuring(cleanup));
143 }),
144);
packages/core/sdk/src/client.ts+13−0
@@ -113,6 +113,19 @@ export interface IntegrationAccountHandoff { · 112 unmodified lines
113 readonly template?: string;113 readonly template?: string;
114 /** Non-secret connection label to prefill. */114 /** Non-secret connection label to prefill. */
115 readonly label?: string;115 readonly label?: string;
116 /** Present when the agent handed off a CONFIDENTIAL OAuth-app registration
117 * (via `oauth.clients.createHandoff`): the accounts UI opens the
118 * Register-OAuth-app form pre-filled with these NON-secret fields, and the
119 * human types the client secret directly into the browser. */
120 readonly oauthClient?: {
121 /** Preselected client slug; when set the form's slug is fixed. */
122 readonly slug?: string;
123 readonly grant?: string;
124 readonly clientId?: string;
125 readonly authorizationUrl?: string;
126 readonly tokenUrl?: string;
127 readonly resource?: string;
128 };
116}129}
117 130
118/** Outcome of applying an edit-sheet section's staged change. `summary` is131/** Outcome of applying an edit-sheet section's staged change. `summary` is
packages/core/sdk/src/core-tools.ts+130−3
@@ -231,6 +231,10 @@ const OAuthClientOutput = Schema.Struct({ · 230 unmodified lines
231const OAuthClientsListOutput = Schema.Struct({231const OAuthClientsListOutput = Schema.Struct({
232 clients: Schema.Array(OAuthClientOutput),232 clients: Schema.Array(OAuthClientOutput),
233});233});
234// No `clientSecret`: a confidential client's secret must never cross the agent
235// boundary (it would land in the LLM context window). This tool registers a
236// PUBLIC client only; a secret-bearing app is registered by the human through
237// `oauth.clients.createHandoff`, which deep-links them to the web form.
234const OAuthCreateClientInput = Schema.Struct({238const OAuthCreateClientInput = Schema.Struct({
235 owner: OwnerSchema,239 owner: OwnerSchema,
236 slug: Schema.String,240 slug: Schema.String,
@@ -238,9 +242,27 @@ const OAuthCreateClientInput = Schema.Struct({ · 1 unmodified line
238 tokenUrl: Schema.String,242 tokenUrl: Schema.String,
239 grant: OAuthGrantSchema,243 grant: OAuthGrantSchema,
240 clientId: Schema.String,244 clientId: Schema.String,
241 clientSecret: Schema.String,
242 resource: Schema.optional(Schema.NullOr(Schema.String)),245 resource: Schema.optional(Schema.NullOr(Schema.String)),
243});246});
247// Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret
248// fields the form pre-fills. The client secret is typed by the human in the web
249// UI, exactly like a pasted connection credential, so it never reaches the
250// agent. Mirrors `ConnectionCreateHandoffInput`.
251const OAuthCreateClientHandoffInput = Schema.Struct({
252 integration: Schema.String,
253 owner: Schema.optional(OwnerSchema),
254 slug: Schema.optional(Schema.String),
255 grant: Schema.optional(OAuthGrantSchema),
256 clientId: Schema.optional(Schema.String),
257 authorizationUrl: Schema.optional(Schema.String),
258 tokenUrl: Schema.optional(Schema.String),
259 resource: Schema.optional(Schema.NullOr(Schema.String)),
260 label: Schema.optional(Schema.String),
261});
262const OAuthCreateClientHandoffOutput = Schema.Struct({
263 url: Schema.String,
264 instructions: Schema.String,
265});
244const OAuthClientOutputRef = Schema.Struct({266const OAuthClientOutputRef = Schema.Struct({
245 client: Schema.String,267 client: Schema.String,
246});268});
@@ -321,6 +343,8 @@ const PolicyUpdateInputStd = schemaToStandard(PolicyUpdateInput); · 74 unmodified lines
321const PolicyRemoveInputStd = schemaToStandard(PolicyRemoveInput);343const PolicyRemoveInputStd = schemaToStandard(PolicyRemoveInput);
322const OAuthClientsListOutputStd = schemaToStandard(OAuthClientsListOutput);344const OAuthClientsListOutputStd = schemaToStandard(OAuthClientsListOutput);
323const OAuthCreateClientInputStd = schemaToStandard(OAuthCreateClientInput);345const OAuthCreateClientInputStd = schemaToStandard(OAuthCreateClientInput);
346const OAuthCreateClientHandoffInputStd = schemaToStandard(OAuthCreateClientHandoffInput);
347const OAuthCreateClientHandoffOutputStd = schemaToStandard(OAuthCreateClientHandoffOutput);
324const OAuthClientOutputRefStd = schemaToStandard(OAuthClientOutputRef);348const OAuthClientOutputRefStd = schemaToStandard(OAuthClientOutputRef);
325const OAuthRegisterDynamicInputStd = schemaToStandard(OAuthRegisterDynamicInput);349const OAuthRegisterDynamicInputStd = schemaToStandard(OAuthRegisterDynamicInput);
326const OAuthRemoveClientInputStd = schemaToStandard(OAuthRemoveClientInput);350const OAuthRemoveClientInputStd = schemaToStandard(OAuthRemoveClientInput);
@@ -445,6 +469,31 @@ const connectionCreateHandoffUrl = ( · 118 unmodified lines
445 return new URL(path, webBaseUrl.endsWith("/") ? webBaseUrl : `${webBaseUrl}/`).toString();469 return new URL(path, webBaseUrl.endsWith("/") ? webBaseUrl : `${webBaseUrl}/`).toString();
446};470};
447 471
472const oauthClientCreateHandoffUrl = (
473 webBaseUrl: string | undefined,
474 orgSlug: string | undefined,
475 input: typeof OAuthCreateClientHandoffInput.Type,
476): string => {
477 // `oauthClient=1` flips the integration's Add-account flow straight into the
478 // Register-OAuth-app form; the rest pre-fill its NON-secret fields. The client
479 // secret is deliberately absent: the human types it in the browser, so it is
480 // never placed in this URL (nor in the agent's context). Same builder shape as
481 // `connectionCreateHandoffUrl`.
482 const search = new URLSearchParams({ addAccount: "1", oauthClient: "1" });
483 if (input.owner !== undefined) search.set("owner", input.owner);
484 if (input.slug !== undefined) search.set("clientSlug", input.slug);
485 if (input.grant !== undefined) search.set("grant", input.grant);
486 if (input.clientId !== undefined) search.set("clientId", input.clientId);
487 if (input.authorizationUrl !== undefined) search.set("authorizationUrl", input.authorizationUrl);
488 if (input.tokenUrl !== undefined) search.set("tokenUrl", input.tokenUrl);
489 if (input.resource != null && input.resource.length > 0) search.set("resource", input.resource);
490 if (input.label !== undefined) search.set("label", input.label);
491 const orgPrefix = orgSlug !== undefined && orgSlug.length > 0 ? `/${orgSlug}` : "";
492 const path = `${orgPrefix}/integrations/${encodeURIComponent(input.integration)}?${search.toString()}`;
493 if (webBaseUrl === undefined || webBaseUrl.length === 0) return path;
494 return new URL(path, webBaseUrl.endsWith("/") ? webBaseUrl : `${webBaseUrl}/`).toString();
495};
496
448export interface CoreToolsPluginOptions {497export interface CoreToolsPluginOptions {
449 readonly webBaseUrl?: string;498 readonly webBaseUrl?: string;
450 /** The bound org's URL slug, prefixed onto browser-handoff URLs so they open499 /** The bound org's URL slug, prefixed onto browser-handoff URLs so they open
@@ -526,6 +575,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 75 unmodified lines
526 'Low-level create or replace for a saved connection from provider item references. For a no-auth integration (public MCP server, public REST API), pass `template: "none"` with no `from`/`inputs` to wire it up directly. For normal API keys/tokens, use `connections.createHandoff` so the user enters the credential in the web UI. OAuth credentials should use `oauth.start`.',575 'Low-level create or replace for a saved connection from provider item references. For a no-auth integration (public MCP server, public REST API), pass `template: "none"` with no `from`/`inputs` to wire it up directly. For normal API keys/tokens, use `connections.createHandoff` so the user enters the credential in the web UI. OAuth credentials should use `oauth.start`.',
527 inputSchema: ConnectionCreateInputStd,576 inputSchema: ConnectionCreateInputStd,
528 outputSchema: ConnectionOutputStd,577 outputSchema: ConnectionOutputStd,
578 // Creating a connection binds a credential reference and roots a new
579 // tool catalog: every tool that connection produces then becomes
580 // callable. Even the no-auth (`template: "none"`) path pulls tools
581 // from an arbitrary endpoint. Prompt-injected code could silently
582 // wire an attacker-chosen integration or credential, so this is
583 // approval-gated (the v1 `sources.configure` carried the same guard).
584 annotations: { requiresApproval: true },
529 execute: (input: typeof ConnectionCreateInput.Type, { ctx }) =>585 execute: (input: typeof ConnectionCreateInput.Type, { ctx }) =>
530 Effect.map(586 Effect.map(
531 ctx.connections.create(createConnectionInputFromTool(input)),587 ctx.connections.create(createConnectionInputFromTool(input)),
@@ -553,6 +609,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 21 unmodified lines
553 "Remove a saved connection and its produced tools by owner, integration, and connection name.",609 "Remove a saved connection and its produced tools by owner, integration, and connection name.",
554 inputSchema: ConnectionRefInputStd,610 inputSchema: ConnectionRefInputStd,
555 outputSchema: RemovedOutputStd,611 outputSchema: RemovedOutputStd,
612 // Deleting a connection drops it and every tool it produced, which
613 // prompt-injected code could use to disrupt an integration or force a
614 // re-add flow. Approval-gated, matching v1 `sources.remove`.
615 annotations: { requiresApproval: true },
556 execute: (input: typeof ConnectionRefInput.Type, { ctx }) =>616 execute: (input: typeof ConnectionRefInput.Type, { ctx }) =>
557 Effect.map(ctx.connections.remove(connectionRefFromInput(input)), () => ({617 Effect.map(ctx.connections.remove(connectionRefFromInput(input)), () => ({
558 removed: true,618 removed: true,
@@ -564,6 +624,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 5 unmodified lines
564 "Re-run an integration's tool production for a saved connection, replacing that connection's persisted tools.",624 "Re-run an integration's tool production for a saved connection, replacing that connection's persisted tools.",
565 inputSchema: ConnectionRefInputStd,625 inputSchema: ConnectionRefInputStd,
566 outputSchema: ConnectionsRefreshOutputStd,626 outputSchema: ConnectionsRefreshOutputStd,
627 // Refresh replaces a connection's persisted tool set; for a mutable
628 // upstream (an MCP server whose catalog can change) this can swap in
629 // different tools without confirmation. Approval-gated, matching v1
630 // `sources.refresh`.
631 annotations: { requiresApproval: true },
567 execute: (input: typeof ConnectionRefInput.Type, { ctx }) =>632 execute: (input: typeof ConnectionRefInput.Type, { ctx }) =>
568 Effect.map(ctx.connections.refresh(connectionRefFromInput(input)), (tools) => ({633 Effect.map(ctx.connections.refresh(connectionRefFromInput(input)), (tools) => ({
569 tools: tools.map(toolToOutput),634 tools: tools.map(toolToOutput),
@@ -617,9 +682,20 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 47 unmodified lines
617 tool({682 tool({
618 name: "oauth.clients.create",683 name: "oauth.clients.create",
619 description:684 description:
620 "Register or replace an owner-scoped OAuth client from explicit client credentials. Use grant `client_credentials` for machine OAuth or `authorization_code` for browser consent flows.",685 "Register or replace an owner-scoped OAuth client WITHOUT a client secret: a PUBLIC client (PKCE / authorization_code) or a discovery-prefill placeholder. To register a CONFIDENTIAL client that has a secret, call `oauth.clients.createHandoff` instead so the human enters the secret in the web UI; never pass a client secret through this tool.",
621 inputSchema: OAuthCreateClientInputStd,686 inputSchema: OAuthCreateClientInputStd,
622 outputSchema: OAuthClientOutputRefStd,687 outputSchema: OAuthClientOutputRefStd,
688 // This persists an OAuth client and REPLACES on slug collision. It
689 // takes NO client secret: a secret would have to travel through the
690 // agent's context window, so a confidential app is registered by the
691 // human via `oauth.clients.createHandoff`. An empty secret registers a
692 // PUBLIC client. The remaining risk is the write itself: prompt-injected
693 // code could register a client with an attacker-controlled
694 // authorizationUrl/tokenUrl, then drive `oauth.start` to mint a
695 // connection and route the user's tokens to the attacker. The
696 // highest-value gate here; matches v1 `sources.bindings.set`, which
697 // guarded credential writes.
698 annotations: { requiresApproval: true },
623 execute: (input: typeof OAuthCreateClientInput.Type, { ctx }) =>699 execute: (input: typeof OAuthCreateClientInput.Type, { ctx }) =>
624 Effect.map(700 Effect.map(
625 ctx.oauth.createClient({701 ctx.oauth.createClient({
@@ -629,18 +705,43 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 3 unmodified lines
629 tokenUrl: input.tokenUrl,705 tokenUrl: input.tokenUrl,
630 grant: input.grant,706 grant: input.grant,
631 clientId: input.clientId,707 clientId: input.clientId,
632 clientSecret: input.clientSecret,708 // No secret crosses the agent boundary; an empty secret registers
709 // a public client. Confidential clients go through
710 // `oauth.clients.createHandoff`.
711 clientSecret: "",
633 resource: input.resource ?? null,712 resource: input.resource ?? null,
634 }),713 }),
635 (client) => ({ client: String(client) }),714 (client) => ({ client: String(client) }),
636 ),715 ),
637 }),716 }),
717 tool({
718 name: "oauth.clients.createHandoff",
719 description:
720 "Return a browser URL that opens the Register-OAuth-app form for one integration, pre-filled with the non-secret fields (client id, endpoints, grant). Use this for any CONFIDENTIAL OAuth app: the user types the client secret directly in the web UI instead of sending it through the agent. After they register the app, call `oauth.clients.list` to discover its owner and slug, then `oauth.start`.",
721 inputSchema: OAuthCreateClientHandoffInputStd,
722 outputSchema: OAuthCreateClientHandoffOutputStd,
723 // Pure URL builder: no DB write, no token, no secret. This is the SAFE
724 // path (it routes the secret to the human in the browser), so it is
725 // deliberately NOT approval-gated, mirroring `connections.createHandoff`.
726 execute: (input: typeof OAuthCreateClientHandoffInput.Type) => {
727 const url = oauthClientCreateHandoffUrl(options.webBaseUrl, options.orgSlug, input);
728 return Effect.succeed({
729 url,
730 instructions:
731 "Ask the user to open this URL and register the OAuth app in the Executor web UI, entering the client secret there. Do not ask them to paste the client secret into chat. After they finish, call oauth.clients.list to find the registered client (owner + slug), then oauth.start.",
732 });
733 },
734 }),
638 tool({735 tool({
639 name: "oauth.clients.registerDynamic",736 name: "oauth.clients.registerDynamic",
640 description:737 description:
641 "Register an OAuth client through RFC 7591 Dynamic Client Registration and save the minted client for later `oauth.start` calls.",738 "Register an OAuth client through RFC 7591 Dynamic Client Registration and save the minted client for later `oauth.start` calls.",
642 inputSchema: OAuthRegisterDynamicInputStd,739 inputSchema: OAuthRegisterDynamicInputStd,
643 outputSchema: OAuthClientOutputRefStd,740 outputSchema: OAuthClientOutputRefStd,
741 // Same risk class as `oauth.clients.create`: registers a client at a
742 // caller-supplied endpoint and persists the minted credentials for
743 // later `oauth.start` abuse. Approval-gated. See `oauth.clients.create`.
744 annotations: { requiresApproval: true },
644 execute: (input: typeof OAuthRegisterDynamicInput.Type, { ctx }) =>745 execute: (input: typeof OAuthRegisterDynamicInput.Type, { ctx }) =>
645 Effect.map(746 Effect.map(
646 ctx.oauth.registerDynamicClient({747 ctx.oauth.registerDynamicClient({
@@ -668,6 +769,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 21 unmodified lines
668 "Remove an owner-scoped OAuth client by owner and slug. Existing connections are not cascaded.",769 "Remove an owner-scoped OAuth client by owner and slug. Existing connections are not cascaded.",
669 inputSchema: OAuthRemoveClientInputStd,770 inputSchema: OAuthRemoveClientInputStd,
670 outputSchema: RemovedOutputStd,771 outputSchema: RemovedOutputStd,
772 // Removing a client breaks token refresh for every connection that
773 // depends on it (a silent DoS) and can force re-auth through an
774 // attacker-supplied replacement. Approval-gated, matching v1
775 // `sources.bindings.remove`.
776 annotations: { requiresApproval: true },
671 execute: (input: typeof OAuthRemoveClientInput.Type, { ctx }) =>777 execute: (input: typeof OAuthRemoveClientInput.Type, { ctx }) =>
672 Effect.map(778 Effect.map(
673 ctx.oauth.removeClient(input.owner as Owner, OAuthClientSlug.make(input.slug)),779 ctx.oauth.removeClient(input.owner as Owner, OAuthClientSlug.make(input.slug)),
@@ -696,6 +802,14 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 22 unmodified lines
696 "Start OAuth through a registered client to mint a connection for an integration. `client_credentials` clients return `connected`; authorization-code clients return an authorization URL and state.",802 "Start OAuth through a registered client to mint a connection for an integration. `client_credentials` clients return `connected`; authorization-code clients return an authorization URL and state.",
697 inputSchema: OAuthStartInputStd,803 inputSchema: OAuthStartInputStd,
698 outputSchema: OAuthStartOutputStd,804 outputSchema: OAuthStartOutputStd,
805 // This is the materialization step that turns a registered client
806 // into a live connection. For `client_credentials` it completes
807 // synchronously (status `connected`) with no browser step, so a
808 // prompt-injected call against an attacker-registered client mints a
809 // credentialed connection with no human in the loop. The
810 // authorization-code path already returns a URL the user must visit,
811 // but one gate on the whole tool covers the silent path cleanly.
812 annotations: { requiresApproval: true },
699 execute: (input: typeof OAuthStartInput.Type, { ctx }) =>813 execute: (input: typeof OAuthStartInput.Type, { ctx }) =>
700 Effect.map(814 Effect.map(
701 ctx.oauth.start({815 ctx.oauth.start({
@@ -752,6 +866,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 50 unmodified lines
752 "Create a tool policy. `pattern` matches a tool address tail (`integration.connection.tool`, `integration.*`, `*`); `action` is approve/require_approval/block. `owner` is org (workspace guardrail) or user (personal).",866 "Create a tool policy. `pattern` matches a tool address tail (`integration.connection.tool`, `integration.*`, `*`); `action` is approve/require_approval/block. `owner` is org (workspace guardrail) or user (personal).",
753 inputSchema: PolicyCreateInputStd,867 inputSchema: PolicyCreateInputStd,
754 outputSchema: PolicyOutputStd,868 outputSchema: PolicyOutputStd,
869 // A policy decides which tools run without confirmation, so creating
870 // one can silence every other approval gate (e.g. `approve *`). It
871 // must itself require approval, otherwise prompt-injected code could
872 // disable approvals by writing its own bypass policy.
873 annotations: { requiresApproval: true },
755 execute: (input: typeof PolicyCreateInput.Type, { ctx }) =>874 execute: (input: typeof PolicyCreateInput.Type, { ctx }) =>
756 Effect.map(875 Effect.map(
757 ctx.core.policies.create({876 ctx.core.policies.create({
@@ -773,6 +892,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 15 unmodified lines
773 description: "Update a tool policy's pattern and/or action by id + owner.",892 description: "Update a tool policy's pattern and/or action by id + owner.",
774 inputSchema: PolicyUpdateInputStd,893 inputSchema: PolicyUpdateInputStd,
775 outputSchema: PolicyOutputStd,894 outputSchema: PolicyOutputStd,
895 // Editing a policy can broaden a pattern or flip an action to
896 // `approve`, weakening an approval gate just as creation can, so it
897 // requires approval too. See `policies.create`.
898 annotations: { requiresApproval: true },
776 execute: (input: typeof PolicyUpdateInput.Type, { ctx }) =>899 execute: (input: typeof PolicyUpdateInput.Type, { ctx }) =>
777 Effect.map(900 Effect.map(
778 ctx.core.policies.update({901 ctx.core.policies.update({
@@ -795,6 +918,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 16 unmodified lines
795 description: "Remove a tool policy by id + owner.",918 description: "Remove a tool policy by id + owner.",
796 inputSchema: PolicyRemoveInputStd,919 inputSchema: PolicyRemoveInputStd,
797 outputSchema: RemovedOutputStd,920 outputSchema: RemovedOutputStd,
921 // Removing a policy can drop a `block` or `require_approval`
922 // guardrail, so deletion is also approval-gated. See
923 // `policies.create`.
924 annotations: { requiresApproval: true },
798 execute: (input: typeof PolicyRemoveInput.Type, { ctx }) =>925 execute: (input: typeof PolicyRemoveInput.Type, { ctx }) =>
799 Effect.map(926 Effect.map(
800 ctx.core.policies.remove({927 ctx.core.policies.remove({
packages/react/src/components/add-account-modal.tsx+54−4
@@ -1,4 +1,4 @@
1import { useEffect, useMemo, useState } from "react";1import { useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -48,7 +48,7 @@ import { · 43 unmodified lines
48} from "../plugins/use-effective-oauth-client";48} from "../plugins/use-effective-oauth-client";
49import { cn } from "../lib/utils";49import { cn } from "../lib/utils";
50import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage";50import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage";
51import { OAuthClientForm } from "./oauth-client-form";51import { OAuthClientForm, type OAuthClientFormPrefill } from "./oauth-client-form";
52import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";52import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";
53import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";53import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";
54import { PlacementLine, type AuthMethod } from "../lib/auth-placements";54import { PlacementLine, type AuthMethod } from "../lib/auth-placements";
@@ -734,6 +734,43 @@ export function AddAccountModal(props: { · 679 unmodified lines
734 setMethodId(initialMethod?.id ?? allMethods[0]!.id);734 setMethodId(initialMethod?.id ?? allMethods[0]!.id);
735 }, [allMethods, initialState?.template, methodId]);735 }, [allMethods, initialState?.template, methodId]);
736 736
737 // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link.
738 // The agent fills in the endpoints/grant/client id it discovered; the client
739 // secret is deliberately absent and is typed by the human in the form below.
740 const oauthClientHandoff = initialState?.oauthClient;
741 const oauthHandoffPrefill = useMemo<OAuthClientFormPrefill | undefined>(() => {
742 if (!oauthClientHandoff) return undefined;
743 const grant =
744 oauthClientHandoff.grant === "authorization_code" ||
745 oauthClientHandoff.grant === "client_credentials"
746 ? oauthClientHandoff.grant
747 : undefined;
748 return {
749 ...(oauthClientHandoff.authorizationUrl
750 ? { authorizationUrl: oauthClientHandoff.authorizationUrl }
751 : {}),
752 ...(oauthClientHandoff.tokenUrl ? { tokenUrl: oauthClientHandoff.tokenUrl } : {}),
753 ...(oauthClientHandoff.resource ? { resource: oauthClientHandoff.resource } : {}),
754 ...(grant ? { grant } : {}),
755 ...(oauthClientHandoff.clientId ? { clientId: oauthClientHandoff.clientId } : {}),
756 };
757 }, [oauthClientHandoff]);
758
759 // Jump straight into the Register-OAuth-app sub-view when the agent handed off
760 // an OAuth-app registration. Fire once per handoff key (tracked by ref) so the
761 // user can cancel back out without it springing open again; retries on later
762 // renders only while the methods list is still empty.
763 const oauthHandoffOpenedKey = useRef<string | null>(null);
764 useEffect(() => {
765 if (!initialState?.oauthClient) return;
766 if (oauthHandoffOpenedKey.current === initialState.key) return;
767 const oauthMethod = allMethods.find((m: AuthMethod) => m.kind === "oauth");
768 if (!oauthMethod) return;
769 oauthHandoffOpenedKey.current = initialState.key;
770 setMethodId(oauthMethod.id);
771 setRegisteringOAuthClient(true);
772 }, [initialState, allMethods]);
773
737 const isOAuth = method?.kind === "oauth";774 const isOAuth = method?.kind === "oauth";
738 const isNoAuth = method?.kind === "none";775 const isNoAuth = method?.kind === "none";
739 // The distinct credential inputs the selected method needs — one per variable776 // The distinct credential inputs the selected method needs — one per variable
@@ -1206,11 +1243,24 @@ export function AddAccountModal(props: { · 466 unmodified lines
1206 String(app.slug),1243 String(app.slug),
1207 )}1244 )}
1208 prefill={{1245 prefill={{
1209 authorizationUrl: method.oauth?.authorizationUrl,1246 authorizationUrl:
1210 tokenUrl: method.oauth?.tokenUrl,1247 oauthHandoffPrefill?.authorizationUrl ?? method.oauth?.authorizationUrl,
1248 tokenUrl: oauthHandoffPrefill?.tokenUrl ?? method.oauth?.tokenUrl,
1211 scopes: method.oauth?.scopes,1249 scopes: method.oauth?.scopes,
1212 registrationEndpoint: method.oauth?.registrationEndpoint,1250 registrationEndpoint: method.oauth?.registrationEndpoint,
1251 ...(oauthHandoffPrefill?.grant ? { grant: oauthHandoffPrefill.grant } : {}),
1252 ...(oauthHandoffPrefill?.clientId
1253 ? { clientId: oauthHandoffPrefill.clientId }
1254 : {}),
1255 ...(oauthHandoffPrefill?.resource != null
1256 ? { resource: oauthHandoffPrefill.resource }
1257 : {}),
1213 }}1258 }}
1259 fixedSlug={
1260 oauthClientHandoff?.slug != null && oauthClientHandoff.slug.length > 0
1261 ? OAuthClientSlug.make(oauthClientHandoff.slug)
1262 : undefined
1263 }
1214 onCreated={(result: { readonly owner: Owner; readonly slug: OAuthClientSlug }) => {1264 onCreated={(result: { readonly owner: Owner; readonly slug: OAuthClientSlug }) => {
1215 setPickedApp(String(result.slug));1265 setPickedApp(String(result.slug));
1216 setRegisteringOAuthClient(false);1266 setRegisteringOAuthClient(false);
packages/react/src/pages/integration-detail.tsx+21−0
@@ -108,11 +108,32 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 107 unmodified lines
108 const owner = search.get("owner");108 const owner = search.get("owner");
109 const template = search.get("template");109 const template = search.get("template");
110 const label = search.get("label");110 const label = search.get("label");
111 // `oauthClient=1` is the OAuth-app registration handoff
112 // (`oauth.clients.createHandoff`): pull the NON-secret prefill fields. The
113 // client secret is never in the URL; the human types it into the form.
114 const oauthClient = ((): IntegrationAccountHandoff["oauthClient"] => {
115 if (search.get("oauthClient") !== "1") return undefined;
116 const slug = search.get("clientSlug");
117 const grant = search.get("grant");
118 const clientId = search.get("clientId");
119 const authorizationUrl = search.get("authorizationUrl");
120 const tokenUrl = search.get("tokenUrl");
121 const resource = search.get("resource");
122 return {
123 ...(slug != null && slug.length > 0 ? { slug } : {}),
124 ...(grant != null && grant.length > 0 ? { grant } : {}),
125 ...(clientId != null && clientId.length > 0 ? { clientId } : {}),
126 ...(authorizationUrl != null && authorizationUrl.length > 0 ? { authorizationUrl } : {}),
127 ...(tokenUrl != null && tokenUrl.length > 0 ? { tokenUrl } : {}),
128 ...(resource != null && resource.length > 0 ? { resource } : {}),
129 };
130 })();
111 return {131 return {
112 key: locationSearch,132 key: locationSearch,
113 ...(owner === "org" || owner === "user" ? { owner } : {}),133 ...(owner === "org" || owner === "user" ? { owner } : {}),
114 ...(template != null && template.length > 0 ? { template } : {}),134 ...(template != null && template.length > 0 ? { template } : {}),
115 ...(label != null && label.length > 0 ? { label } : {}),135 ...(label != null && label.length > 0 ? { label } : {}),
136 ...(oauthClient !== undefined ? { oauthClient } : {}),
116 };137 };
117 }, [locationSearch]);138 }, [locationSearch]);
118 139