Skip to content

Commit 3030c8e

Browse files
committed
feat(tool-result): make ToolResult mandatory and route defects opaquely
Removes the legacy { data, error } envelope shim from the sandbox tool dispatcher. The invoker now passes ToolResult<T> through unchanged and wraps any other plain-value plugin return in ToolResult.ok so the sandbox surface is uniform. Plugin/infra defects no longer pass their message into the sandbox. The dispatcher generates a short hex correlation id, logs the full cause with that id under executor.correlation_id, and rejects with `Internal tool error [<corrId>]`. The QuickJS bridge and the dynamic-worker module template defensively re-stamp the same opaque shape; the MCP host server's top-level execute failure path does the same. ExecutionToolError in-band messages from the execution package's built-in validators (tools.search arg checks, etc.) are still passed through at the QuickJS bridge so model-facing input errors keep their useful diagnostic. Tests: - ToolResult.ok / fail / isToolResult constructor unit tests. - repro tests assert structured upstream payloads now reach the sandbox through ToolResult.error.details (not through .message). - leak tests pin the new invariant: plugin defects only escape as the opaque generic + correlation id; no token / connection string / file path leaks into Error.message. - QuickJS end-to-end defect test confirms the same shape at the sandbox boundary. - Cloud HTTP integration tests, MCP host tests, dynamic-worker invocation tests, and plugin tests updated for the new ToolResult wire shape and the opaque-generic defect contract.
1 parent 38e6a04 commit 3030c8e

22 files changed

Lines changed: 441 additions & 393 deletions

apps/cloud/src/services/sources-api.node.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,15 @@ describe("sources api (HTTP)", () => {
207207
expect(execution.structured).toMatchObject({
208208
status: "completed",
209209
result: {
210-
message: "hello",
211-
suffix: "world",
212-
path: "/echo/hello",
210+
ok: true,
211+
data: {
212+
status: 200,
213+
data: {
214+
message: "hello",
215+
suffix: "world",
216+
path: "/echo/hello",
217+
},
218+
},
213219
},
214220
logs: [],
215221
});
@@ -315,7 +321,7 @@ describe("sources api (HTTP)", () => {
315321
expect(execution.isError).toBe(false);
316322
expect(execution.structured).toMatchObject({
317323
status: "completed",
318-
result: { hello: "Hello Ada" },
324+
result: { ok: true, data: { hello: "Hello Ada" } },
319325
});
320326
const requests = yield* server.requests;
321327
expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true);
@@ -391,7 +397,8 @@ describe("sources api (HTTP)", () => {
391397
expect(execution.structured).toMatchObject({
392398
status: "completed",
393399
result: {
394-
content: [{ type: "text", text: "cloud-mcp-ok" }],
400+
ok: true,
401+
data: [{ type: "text", text: "cloud-mcp-ok" }],
395402
},
396403
});
397404
expect((yield* server.requests).length).toBeGreaterThanOrEqual(2);

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

Lines changed: 43 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Data, Effect, Schema } from "effect";
33

44
import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/sdk";
55
import { makeTestConfig } from "@executor-js/sdk/testing";
6+
import { ExecutionToolError } from "./errors";
67
import { makeExecutorToolInvoker } from "./tool-invoker";
78

89
const EmptyInputSchema = Schema.toStandardSchemaV1(
@@ -11,9 +12,9 @@ const EmptyInputSchema = Schema.toStandardSchemaV1(
1112

1213
const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" }));
1314

14-
// Simulate a realistic plugin-internal tagged error whose `cause` carries
15-
// sensitive internal context (DB connection string, full HTTP request with
16-
// Authorization header echoed back, file paths, stack traces).
15+
// Plugin-internal tagged error whose `cause` carries sensitive internal
16+
// context. The dispatcher must route this through the opaque-generic
17+
// path so none of that context reaches the sandbox via Error.message.
1718
class FakeOpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{
1819
readonly message: string;
1920
readonly cause: unknown;
@@ -33,83 +34,81 @@ const leakyPlugin = definePlugin(() => ({
3334
description: "",
3435
inputSchema: EmptyInputSchema,
3536
handler: () =>
36-
Effect.fail(new FakeOpenApiInvocationError({
37-
message: "HTTP request failed",
38-
cause: {
39-
_tag: "HttpClientError",
40-
request: {
41-
method: "GET",
42-
url: "https://internal.dealcloud/v1/entities?accessToken=SECRET_TOKEN_xyz",
43-
headers: { Authorization: "Bearer SECRET_TOKEN_xyz" },
37+
Effect.fail(
38+
new FakeOpenApiInvocationError({
39+
message: "HTTP request failed",
40+
cause: {
41+
_tag: "HttpClientError",
42+
request: {
43+
method: "GET",
44+
url: "https://internal.dealcloud/v1/entities?accessToken=SECRET_TOKEN_xyz",
45+
headers: { Authorization: "Bearer SECRET_TOKEN_xyz" },
46+
},
47+
stack:
48+
"Error: ECONNREFUSED\n at /home/svc/executor/packages/plugins/openapi/...:142:11",
49+
dbConnString: "postgres://app:p@ssw0rd@10.0.0.5:5432/executor",
4450
},
45-
stack:
46-
"Error: ECONNREFUSED\n at /home/svc/executor/packages/plugins/openapi/...:142:11",
47-
dbConnString: "postgres://app:p@ssw0rd@10.0.0.5:5432/executor",
48-
},
49-
})),
51+
}),
52+
),
5053
},
5154
{
5255
name: "throwsRawError",
5356
description: "",
5457
inputSchema: EmptyInputSchema,
5558
handler: () =>
5659
Effect.fail(
57-
Object.assign(new Error("Internal: secret 'sk_live_abcd' rotation failed"), {
58-
stack:
59-
"Error: Internal: secret 'sk_live_abcd' rotation failed\n at /home/svc/.../secret-store.ts:88",
60-
}),
60+
Object.assign(
61+
// oxlint-disable-next-line executor/no-error-constructor -- boundary: leak test deliberately fails with a raw Error + crafted stack to assert the dispatcher's opaque-generic redaction
62+
new Error("Internal: secret 'sk_live_abcd' rotation failed"),
63+
{
64+
stack:
65+
"Error: Internal: secret 'sk_live_abcd' rotation failed\n at /home/svc/.../secret-store.ts:88",
66+
},
67+
),
6168
),
6269
},
6370
],
6471
},
6572
],
6673
}));
6774

68-
describe("internal-error leak audit", () => {
69-
it.effect("plugin tagged error: only .message escapes, cause stays hidden", () =>
75+
describe("internal-error leak audit (opaque defects)", () => {
76+
it.effect("plugin tagged error: defect surfaces only as opaque generic + correlation id", () =>
7077
Effect.gen(function* () {
71-
const executor = yield* createExecutor(
72-
makeTestConfig({ plugins: [leakyPlugin()] as const }),
73-
);
78+
const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
7479
const invoker = makeExecutorToolInvoker(executor, {
7580
invokeOptions: { onElicitation: acceptAll },
7681
});
7782

78-
const err = yield* Effect.flip(
79-
invoker.invoke({ path: "leaky.failsWithCause", args: {} }),
80-
);
83+
const err = yield* Effect.flip(invoker.invoke({ path: "leaky.failsWithCause", args: {} }));
84+
expect(err).toBeInstanceOf(ExecutionToolError);
85+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
8186
const msg = (err as { message: string }).message;
82-
// eslint-disable-next-line no-console
83-
console.log("[leak failsWithCause]", msg);
84-
85-
expect(msg).toBe("HTTP request failed");
87+
// Must be the canonical opaque shape: "Internal tool error [<hex>]"
88+
expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
89+
// Crucially, no internal context leaks
8690
expect(msg).not.toContain("SECRET_TOKEN_xyz");
8791
expect(msg).not.toContain("p@ssw0rd");
8892
expect(msg).not.toContain("packages/plugins");
8993
expect(msg).not.toContain("HttpClientError");
94+
expect(msg).not.toContain("HTTP request failed");
9095
}),
9196
);
9297

93-
it.effect("plain Error with stack: stack does NOT leak, only message", () =>
98+
it.effect("plain Error with stack: stack and message do NOT escape", () =>
9499
Effect.gen(function* () {
95-
const executor = yield* createExecutor(
96-
makeTestConfig({ plugins: [leakyPlugin()] as const }),
97-
);
100+
const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
98101
const invoker = makeExecutorToolInvoker(executor, {
99102
invokeOptions: { onElicitation: acceptAll },
100103
});
101104

102-
const err = yield* Effect.flip(
103-
invoker.invoke({ path: "leaky.throwsRawError", args: {} }),
104-
);
105+
const err = yield* Effect.flip(invoker.invoke({ path: "leaky.throwsRawError", args: {} }));
106+
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
105107
const msg = (err as { message: string }).message;
106-
// eslint-disable-next-line no-console
107-
console.log("[leak throwsRawError]", msg);
108-
109-
// message itself contains the secret because the plugin put it there —
110-
// that's plugin discipline. But stack and file path should not appear.
108+
expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
111109
expect(msg).not.toContain("secret-store.ts");
112110
expect(msg).not.toContain("at /home/");
111+
expect(msg).not.toContain("sk_live_abcd");
113112
}),
114113
);
115114
});

0 commit comments

Comments
 (0)