Skip to content

Commit fd3ad26

Browse files
committed
Run panel: auto-approve operator-invoked tools
Invoking a tool through the Run/Test panel paused on approval-gated tools and dead-ended on a "This tool requires approval" message, even though the operator clicking Run is itself the approval. Thread an optional autoApprove through the execute path: the execution engine runs the inline accept-all handler instead of intercepting the first elicitation as a pause, so an approval-gated tool runs to completion. The HTTP /executions endpoint takes autoApprove and the Run panel sends it. block policies still fail before any elicitation, so this never bypasses a hard block; the MCP host path is unchanged and still pauses for the model.
1 parent 5422ec3 commit fd3ad26

7 files changed

Lines changed: 163 additions & 6 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Cross-target: the Run/Test panel's backend (`POST /executions`) auto-approves
2+
// approval-gated tools when the operator invokes them, because clicking Run in
3+
// the panel IS the human approval.
4+
//
5+
// The panel sends `autoApprove: true`. Here we drive the same HTTP endpoint the
6+
// panel uses and prove both halves of the contract against ONE tool that gates
7+
// itself: the `policies.create` core tool carries a `requiresApproval`
8+
// annotation, so with no matching policy in play the annotation is the only
9+
// thing that can pause the call.
10+
//
11+
// - without `autoApprove`: the call pauses (the panel would have dead-ended
12+
// on "This tool requires approval"), and the policy is not written.
13+
// - with `autoApprove`: the call runs to completion and the policy is written.
14+
//
15+
// The created policy is a `block` rule on a unique, non-matching pattern, so a
16+
// leak cannot gate another scenario's tools; it is removed in an `ensuring`
17+
// finalizer regardless.
18+
import { randomUUID } from "node:crypto";
19+
20+
import { expect } from "@effect/vitest";
21+
import { Effect } from "effect";
22+
import { composePluginApi } from "@executor-js/api/server";
23+
24+
import { scenario } from "../src/scenario";
25+
import { Api, Target } from "../src/services";
26+
27+
const coreApi = composePluginApi([] as const);
28+
29+
/** Sandbox code that creates a policy through the approval-gated core tool. The
30+
* pattern is unique-per-run and matches no real tool, so the rule is inert. */
31+
const createPolicyCode = (pattern: string) => `
32+
return await tools.executor.coreTools.policies.create({
33+
owner: "user",
34+
pattern: ${JSON.stringify(pattern)},
35+
action: "block",
36+
});
37+
`;
38+
39+
scenario(
40+
"Run panel · autoApprove runs an approval-gated tool that otherwise pauses",
41+
{},
42+
Effect.gen(function* () {
43+
const target = yield* Target;
44+
const apiSurface = yield* Api;
45+
const identity = yield* target.newIdentity();
46+
const client = yield* apiSurface.client(coreApi, identity);
47+
const pattern = `run-auto-approve-${randomUUID().slice(0, 8)}.*`;
48+
const code = createPolicyCode(pattern);
49+
50+
const cleanup = client.policies.list().pipe(
51+
Effect.flatMap((list) =>
52+
Effect.forEach(
53+
list.filter((p) => p.pattern === pattern),
54+
(p) =>
55+
client.policies
56+
.remove({ params: { policyId: p.id }, payload: { owner: "user" } })
57+
.pipe(Effect.ignore),
58+
),
59+
),
60+
Effect.ignore,
61+
);
62+
63+
yield* Effect.gen(function* () {
64+
// Baseline: without autoApprove the gated tool pauses (the panel's old
65+
// dead-end), and the side effect must not have happened.
66+
const gated = yield* client.executions.execute({ payload: { code } });
67+
expect(gated.status, "a gated tool pauses without autoApprove").toBe("paused");
68+
69+
const beforeApproval = yield* client.policies.list();
70+
expect(
71+
beforeApproval.some((p) => p.pattern === pattern),
72+
"the policy is not written while the call is paused for approval",
73+
).toBe(false);
74+
75+
// Release the paused fiber so it does not linger waiting on a response.
76+
if (gated.status === "paused") {
77+
const executionId = (gated.structured as { readonly executionId?: string }).executionId;
78+
if (executionId) {
79+
yield* client.executions
80+
.resume({ params: { executionId }, payload: { action: "cancel" } })
81+
.pipe(Effect.ignore);
82+
}
83+
}
84+
85+
// With autoApprove the operator IS the approver: the same call runs to
86+
// completion and the side effect lands.
87+
const approved = yield* client.executions.execute({
88+
payload: { code, autoApprove: true },
89+
});
90+
expect(approved.status, "autoApprove runs the gated tool to completion").toBe("completed");
91+
if (approved.status !== "completed") return; // narrowing only
92+
expect(approved.isError, "the auto-approved run is not an error").toBe(false);
93+
94+
const afterApproval = yield* client.policies.list();
95+
expect(
96+
afterApproval.some((p) => p.pattern === pattern),
97+
"the policy is written once autoApprove runs the gated tool",
98+
).toBe(true);
99+
}).pipe(Effect.ensuring(cleanup));
100+
}),
101+
);

packages/core/api/src/executions/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { InternalError } from "@executor-js/sdk/shared";
99

1010
const ExecuteRequest = Schema.Struct({
1111
code: Schema.String,
12+
// When true the caller is the human approver: approval-gated tools run to
13+
// completion instead of pausing. Set by the operator-facing Run/Test panel,
14+
// where clicking Run is itself the approval. `block` policies still apply.
15+
autoApprove: Schema.optional(Schema.Boolean),
1216
});
1317

1418
const CompletedResult = Schema.Struct({

packages/core/api/src/handlers/executions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions"
3434
capture(
3535
Effect.gen(function* () {
3636
const engine = yield* ExecutionEngineService;
37-
const outcome = yield* captureEngineError(engine.executeWithPause(payload.code));
37+
const outcome = yield* captureEngineError(
38+
engine.executeWithPause(payload.code, { autoApprove: payload.autoApprove }),
39+
);
3840

3941
if (outcome.status === "completed") {
4042
const formatted = formatExecuteResult(outcome.result);

packages/core/execution/src/engine.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ export type ResumeResponse = {
5353
readonly content?: Record<string, unknown>;
5454
};
5555

56+
// Auto-accept every elicitation. Used by the `autoApprove` path where the
57+
// caller is itself the human approver (the operator-facing Run/Test panel).
58+
const acceptAllHandler: ElicitationHandler = () => Effect.succeed({ action: "accept" });
59+
5660
// ---------------------------------------------------------------------------
5761
// Result formatting
5862
// ---------------------------------------------------------------------------
@@ -370,8 +374,17 @@ export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError>
370374
* Execute code, intercepting the first elicitation as a pause point.
371375
* Use this when the host doesn't support inline elicitation.
372376
* Returns either a completed result or a paused execution that can be resumed.
377+
*
378+
* `options.autoApprove` treats the caller as the human in the loop: every
379+
* elicitation is accepted inline, so an approval-gated tool runs to
380+
* completion instead of pausing. The operator-facing Run/Test panel sets
381+
* this because clicking Run IS the approval. `block` policies still fail
382+
* before any elicitation, so this never bypasses a hard block.
373383
*/
374-
readonly executeWithPause: (code: string) => Effect.Effect<ExecutionResult, E>;
384+
readonly executeWithPause: (
385+
code: string,
386+
options?: { readonly autoApprove?: boolean },
387+
) => Effect.Effect<ExecutionResult, E>;
375388

376389
/**
377390
* Resume a paused execution. Returns a completed result, a new pause, or
@@ -455,12 +468,24 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu
455468
* The sandbox is forked as a daemon because paused executions can outlive the
456469
* caller scope that returned the first pause, such as an HTTP request handler.
457470
*/
458-
const startPausableExecution = Effect.fn("mcp.execute")(function* (code: string) {
471+
const startPausableExecution = Effect.fn("mcp.execute")(function* (
472+
code: string,
473+
options?: { readonly autoApprove?: boolean },
474+
) {
459475
yield* Effect.annotateCurrentSpan({
460476
"mcp.execute.mode": "pausable",
461477
"mcp.execute.code_length": code.length,
462478
});
463479

480+
// Operator-approved invoke: run through the inline path with an accept-all
481+
// handler so an approval gate resolves itself instead of pausing. Never
482+
// pauses, so the caller always gets a completed result.
483+
if (options?.autoApprove) {
484+
yield* Effect.annotateCurrentSpan({ "mcp.execute.auto_approve": true });
485+
const result = yield* runInlineExecution(code, { onElicitation: acceptAllHandler });
486+
return { status: "completed", result } satisfies ExecutionResult;
487+
}
488+
464489
// Queue preserves pauses that arrive before the previous approval has
465490
// returned to the caller, which can happen with concurrent tool calls.
466491
const pauseQueue = yield* Queue.unbounded<InternalPausedExecution<E>>();

packages/core/execution/src/promise.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ export type ExecutionEngine = {
4141
code: string,
4242
options: { readonly onElicitation: ElicitationHandler },
4343
) => Promise<ExecuteResult>;
44-
readonly executeWithPause: (code: string) => Promise<ExecutionResult>;
44+
readonly executeWithPause: (
45+
code: string,
46+
options?: { readonly autoApprove?: boolean },
47+
) => Promise<ExecutionResult>;
4548
readonly resume: (
4649
executionId: string,
4750
response: ResumeResponse,
@@ -149,7 +152,7 @@ export const toPromiseExecutionEngine = <E extends Cause.YieldableError>(
149152
Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie),
150153
}),
151154
),
152-
executeWithPause: (code) => Effect.runPromise(engine.executeWithPause(code)),
155+
executeWithPause: (code, options) => Effect.runPromise(engine.executeWithPause(code, options)),
153156
resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)),
154157
getPausedExecution: (executionId) => Effect.runPromise(engine.getPausedExecution(executionId)),
155158
getDescription: () => Effect.runPromise(engine.getDescription),

packages/core/execution/src/tool-invoker.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,26 @@ describe("pause/resume with multiple elicitations", () => {
13461346
{ timeout: 10000 },
13471347
);
13481348

1349+
it.effect(
1350+
"autoApprove runs an eliciting tool to completion instead of pausing",
1351+
() =>
1352+
Effect.gen(function* () {
1353+
const executor = yield* makeElicitingExecutor();
1354+
const engine = createExecutionEngine({ executor, codeExecutor });
1355+
const code = "return await tools.api.org.main.singleApproval({});";
1356+
1357+
// Same tool that pauses without autoApprove (see the tests above) runs
1358+
// straight through when the caller is the approver: no pause, no
1359+
// executionId to resume, just the side effect's result.
1360+
const outcome = yield* engine.executeWithPause(code, { autoApprove: true });
1361+
expect(outcome.status, "autoApprove never pauses").toBe("completed");
1362+
if (outcome.status !== "completed") return;
1363+
expect(outcome.result.error).toBeUndefined();
1364+
expect(outcome.result.result).toMatchObject({ ok: true });
1365+
}),
1366+
{ timeout: 10000 },
1367+
);
1368+
13491369
// live clock: the sandbox timeout is a real timer, so the test must
13501370
// actually wait for it rather than suspend on the virtual TestClock.
13511371
it.live(

packages/react/src/components/tool-run-panel.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,10 @@ export function ToolRunPanel(props: {
215215

216216
// Read-only invoke: pass the validated args through as JSON. Empty
217217
// `reactivityKeys` — invoking a tool doesn't mutate `tools.list`.
218+
// `autoApprove` because the operator clicked Run: that IS the approval, so
219+
// an approval-gated tool should run here instead of dead-ending on a pause.
218220
const code = `return await tools[${JSON.stringify(addressNoPrefix)}](${JSON.stringify(parsed)});`;
219-
const exit = await doExecute({ payload: { code }, reactivityKeys: [] });
221+
const exit = await doExecute({ payload: { code, autoApprove: true }, reactivityKeys: [] });
220222
setRunning(false);
221223

222224
if (Exit.isFailure(exit)) {

0 commit comments

Comments
 (0)