From a5cbdfe20af75e97363d30867b865b8bd9bb747f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 16 Sep 2026 22:59:38 +0900 Subject: [PATCH 1/3] test(eval): RED contract for call-scoped kernel-tool invoke Covers the policy seam (allow/deny/absent, deny-wins, exact name match, typed refusal payload), the live-worker kernel seam (denied nested host call never reaches the bridge, allowed one does, scope drops at settle, parent cell and queue unaffected) and the real-worker e2e where a host tool invokes the parent closures under scope.tools.deny. Refs #1731 --- .../kernel-tools-invoke-scope-e2e.test.ts | 189 ++++++++++++++++ .../test/kernel-tools-invoke-scope.test.ts | 211 ++++++++++++++++++ .../test/kernel-tools-scope.test.ts | 42 ++++ 3 files changed, 442 insertions(+) create mode 100644 packages/senpi-codemode/test/kernel-tools-invoke-scope-e2e.test.ts create mode 100644 packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts create mode 100644 packages/senpi-codemode/test/kernel-tools-scope.test.ts diff --git a/packages/senpi-codemode/test/kernel-tools-invoke-scope-e2e.test.ts b/packages/senpi-codemode/test/kernel-tools-invoke-scope-e2e.test.ts new file mode 100644 index 000000000..45792aed0 --- /dev/null +++ b/packages/senpi-codemode/test/kernel-tools-invoke-scope-e2e.test.ts @@ -0,0 +1,189 @@ +import { type AgentToolResult, type ExtensionContext, kernelToolsStorage } from "@code-yeongyu/senpi"; +import { afterEach, describe, expect, it } from "vitest"; +import type { KernelToHostMessage } from "../src/bridge/protocol.ts"; +import { JavaScriptKernel } from "../src/kernels/js/context-manager.ts"; +import type { KernelToolsCapability } from "../src/kernels/js/kernel-tools-types.ts"; +import { createEvalTool } from "../src/tool/eval-tool.ts"; +import type { EvalKernel, EvalKernelManager, EvalLanguage, EvalToolDetails } from "../src/tool/types.ts"; +import { fakeExtensionContext } from "./eval/fakes.ts"; + +type ProbeArgs = { readonly path: string }; + +type DeniedRecord = { readonly code?: string; readonly details?: unknown }; + +type HostObservation = { + readonly invokeScope: boolean; + readonly guarded: unknown; + readonly strict: DeniedRecord; +}; + +/** The read the shipped host performs: `ExtensionContext.kernelTools` is a live `kernelToolsStorage.getStore()`. */ +function hostContext(): ExtensionContext { + return { + ...fakeExtensionContext(), + get kernelTools() { + return kernelToolsStorage.getStore(); + }, + }; +} + +/** Mirrors the session manager: one persistent JS kernel whose onMessage is rebound per cell. */ +class LiveJavaScriptKernelManager implements EvalKernelManager { + #kernel: JavaScriptKernel | undefined; + #dispatch: ((message: KernelToHostMessage) => void) | undefined; + + async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise { + if (language !== "js") throw new Error(`this manager only serves js kernels, got ${language}`); + this.#dispatch = onMessage; + this.#kernel ??= new JavaScriptKernel({ + sessionId: "kernel-tools-invoke-scope-e2e", + cwd: process.cwd(), + parallelPoolWidth: 2, + onMessage: (message) => this.#dispatch?.(message), + }); + return this.#kernel; + } + + async close(): Promise { + await this.#kernel?.close(); + } +} + +function textResult(text: string): AgentToolResult { + return { content: [{ type: "text", text }], details: {} }; +} + +function outputText(cell: AgentToolResult): string { + const part = cell.content[0]; + return part?.type === "text" ? part.text : ""; +} + +function deniedRecord(error: unknown): DeniedRecord { + if (!(error instanceof Error)) return { code: "unknown" }; + return { + ...("code" in error ? { code: String(error.code) } : {}), + ...("details" in error ? { details: error.details } : {}), + }; +} + +/** + * A host tool that resolves the parent's kernel-tool capability exactly like a task/agent tool + * serving a child grant does, then invokes the parent's closures under a call scope that denies the + * host `write` tool. + */ +function scopedProbeTool(ctx: ExtensionContext, observations: HostObservation[], hostCalls: string[]) { + return async (toolName: string, params: unknown): Promise> => { + hostCalls.push(toolName); + if (toolName === "read") return textResult("file-body"); + if (toolName === "write") return textResult("host-write-happened"); + const probe = params as ProbeArgs; + const kernelTools = ctx.kernelTools as KernelToolsCapability | undefined; + if (!kernelTools) throw new Error("kernel tools unavailable at the host dispatch point"); + const described = await kernelTools.describe(["guarded_fs", "strict_fs"]); + const guardedEntry = described.results[0]; + const strictEntry = described.results[1]; + if (guardedEntry?.ok !== true || strictEntry?.ok !== true) throw new Error("kernel tool descriptors missing"); + const scope = { tools: { deny: ["write"] } }; + const guarded = await kernelTools.invoke( + { + name: guardedEntry.descriptor.name, + kernel_generation: guardedEntry.descriptor.kernel_generation, + definition_revision: guardedEntry.descriptor.definition_revision, + args: { path: probe.path }, + call_id: "child-guarded", + }, + { scope }, + ); + let strict: DeniedRecord = { code: "resolved" }; + try { + await kernelTools.invoke( + { + name: strictEntry.descriptor.name, + kernel_generation: strictEntry.descriptor.kernel_generation, + definition_revision: strictEntry.descriptor.definition_revision, + args: { path: probe.path }, + call_id: "child-strict", + }, + { scope }, + ); + } catch (error) { + strict = deniedRecord(error); + } + observations.push({ invokeScope: kernelTools.capabilities.invokeScope, guarded, strict }); + return textResult(JSON.stringify({ guarded, strict })); + }; +} + +const CELL_CODE = [ + "tool(async function guarded_fs(path) {", + " const read = await tool.read({ path });", + " try {", + " await tool.write({ path, content: 'body' });", + " return { read: read.text, denied: null };", + " } catch (error) {", + " return { read: read.text, denied: { code: error.code, details: error.details } };", + " }", + "});", + "tool(async function strict_fs(path) {", + " await tool.write({ path, content: 'body' });", + " return 'unreachable';", + "});", + "return (await tool.probe({ path: 'demo.txt' })).text;", +].join("\n"); + +/** Call-scoped host-tool policy on the real worker (https://github.com/code-yeongyu/senpi/issues/1731). */ +describe("kernel-tool invoke scope on the real worker tool-call path", () => { + let manager: LiveJavaScriptKernelManager | undefined; + + afterEach(async () => { + await manager?.close(); + manager = undefined; + }); + + it("runs an allowed nested host call and fails the denied one closed on the invoking call", async () => { + manager = new LiveJavaScriptKernelManager(); + const ctx = hostContext(); + const observations: HostObservation[] = []; + const hostCalls: string[] = []; + const tool = createEvalTool({ + enabledLanguages: { js: true, py: false, rb: false, jl: false }, + kernelManager: manager, + cellTimeoutSeconds: 30, + executeTool: scopedProbeTool(ctx, observations, hostCalls), + }); + + const cell = await tool.execute( + "kernel-tools-invoke-scope-e2e", + { + language: "js", + code: CELL_CODE, + summary: "register kernel tools whose closures call host read and write, then scope the invokes", + }, + undefined, + undefined, + ctx, + ); + + expect(observations).toEqual([ + { + invokeScope: true, + guarded: { + read: "file-body", + denied: { + code: "kernel_tool_host_denied", + details: { tool: "write", call_id: "child-guarded", reason: "deny" }, + }, + }, + strict: { + code: "kernel_tool_host_denied", + details: { tool: "write", call_id: "child-strict", reason: "deny" }, + }, + }, + ]); + expect(hostCalls).toEqual(["probe", "read"]); + expect(cell.details.isError).toBeFalsy(); + expect(cell.details.toolCalls[0]).toMatchObject({ name: "probe", ok: true }); + expect(outputText(cell)).toContain("kernel_tool_host_denied"); + expect(ctx.kernelTools).toBeUndefined(); + }); +}); diff --git a/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts b/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts new file mode 100644 index 000000000..69025438d --- /dev/null +++ b/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import type { KernelToHostMessage } from "../src/bridge/protocol.ts"; +import { JavaScriptKernel } from "../src/kernels/js/context-manager.ts"; +import type { + KernelToolDescriptor, + KernelToolsInvokeOptions, + KernelToolsInvokeScope, +} from "../src/kernels/js/kernel-tools-types.ts"; + +type ToolCallMessage = Extract; +type ResultMessage = Extract; + +/** + * The parent cell registers the closures an in-process child would be granted, then parks on a host + * tool so every invoke below runs against a live cell on the reentrant pump. + */ +const PARENT_CELL = [ + "tool(async function fetch_path(path) { return await tool.read({ path }); });", + "tool(async function store_path(path) { return await tool.write({ path, content: 'body' }); });", + "tool(async function guarded_store(path) {", + " try {", + " await tool.write({ path, content: 'body' });", + " return { denied: null };", + " } catch (error) {", + " return { denied: { name: error.name, code: error.code, details: error.details } };", + " }", + "});", + "await tool.hold({});", + "return 'parent-done';", +].join("\n"); + +type ScopeHarness = { + readonly kernel: JavaScriptKernel; + readonly hostToolCalls: readonly string[]; + readonly parent: Promise; + readonly hold: ToolCallMessage; + invoke(name: string, callId: string, options?: KernelToolsInvokeOptions): Promise; + nextHostCall(): Promise; +}; + +function scope(tools: KernelToolsInvokeScope["tools"]): KernelToolsInvokeOptions { + return { scope: { tools } }; +} + +async function bounded(promise: Promise, label: string): Promise { + const timeout = Promise.withResolvers(); + const timer = setTimeout(() => timeout.reject(new Error(`${label} timed out after 8000ms`)), 8_000); + try { + return await Promise.race([promise, timeout.promise]); + } finally { + clearTimeout(timer); + } +} + +async function withScopedParent(fn: (harness: ScopeHarness) => Promise): Promise { + const hostToolCalls: string[] = []; + const kernel = new JavaScriptKernel({ + sessionId: "kernel-tools-invoke-scope", + cwd: process.cwd(), + parallelPoolWidth: 2, + onMessage: (message) => { + if (message.type === "tool-call") hostToolCalls.push(message.toolName); + }, + }); + try { + const parent = kernel.run({ cellId: "scope-parent", code: PARENT_CELL, timeoutMs: 8_000 }); + const hold = await bounded(kernel.nextToolCall(), "parent hold"); + expect(hold.toolName).toBe("hold"); + const described = await kernel.describeKernelTools(["fetch_path", "store_path", "guarded_store"]); + const descriptors = new Map(); + for (const entry of described.results) { + if (!entry.ok) throw new Error(`descriptor missing: ${entry.name}`); + descriptors.set(entry.name, entry.descriptor); + } + return await fn({ + kernel, + hostToolCalls, + parent, + hold, + invoke: (name, callId, options) => { + const descriptor = descriptors.get(name); + if (!descriptor) throw new Error(`descriptor missing: ${name}`); + return kernel.invokeKernelTool( + { + name: descriptor.name, + kernel_generation: descriptor.kernel_generation, + definition_revision: descriptor.definition_revision, + args: { path: "demo.txt" }, + call_id: callId, + }, + options, + ); + }, + nextHostCall: () => bounded(kernel.nextToolCall(), "nested host call"), + }); + } finally { + await kernel.close(); + } +} + +/** Call-scoped host-tool policy for kernel-tool invoke (https://github.com/code-yeongyu/senpi/issues/1731). */ +describe("kernel-tool invoke scope", () => { + it("refuses a denied nested host call on the invoking call's channel without touching the bridge", async () => { + await withScopedParent(async (harness) => { + const invoke = harness.invoke("store_path", "deny-1", scope({ deny: ["write"] })); + await expect(invoke).rejects.toMatchObject({ + code: "kernel_tool_host_denied", + details: { tool: "write", call_id: "deny-1", reason: "deny" }, + }); + expect(harness.hostToolCalls).toEqual(["hold"]); + }); + }); + + it("hands the closure a rejected promise it can observe, keeping the invoke successful", async () => { + await withScopedParent(async (harness) => { + await expect(harness.invoke("guarded_store", "deny-2", scope({ deny: ["write"] }))).resolves.toEqual({ + denied: { + name: "KernelToolError", + code: "kernel_tool_host_denied", + details: { tool: "write", call_id: "deny-2", reason: "deny" }, + }, + }); + expect(harness.hostToolCalls).toEqual(["hold"]); + }); + }); + + it("lets an allowed nested host call cross the bridge under the same scope", async () => { + await withScopedParent(async (harness) => { + const invoke = harness.invoke("fetch_path", "allow-1", scope({ allow: ["read"], deny: ["write"] })); + const read = await harness.nextHostCall(); + expect(read).toMatchObject({ toolName: "read", args: { path: "demo.txt" } }); + harness.kernel.deliverToolReply({ type: "tool-reply", callId: read.callId, ok: true, value: "nested-body" }); + await expect(invoke).resolves.toBe("nested-body"); + }); + }); + + it("refuses a host tool outside the allow list with reason allow", async () => { + await withScopedParent(async (harness) => { + await expect(harness.invoke("store_path", "allow-2", scope({ allow: ["read"] }))).rejects.toMatchObject({ + code: "kernel_tool_host_denied", + details: { tool: "write", call_id: "allow-2", reason: "allow" }, + }); + expect(harness.hostToolCalls).toEqual(["hold"]); + }); + }); + + it("lets deny win over allow when a tool appears in both lists", async () => { + await withScopedParent(async (harness) => { + await expect( + harness.invoke("store_path", "both-1", scope({ allow: ["read", "write"], deny: ["write"] })), + ).rejects.toMatchObject({ + code: "kernel_tool_host_denied", + details: { tool: "write", call_id: "both-1", reason: "deny" }, + }); + }); + }); + + it("keeps an invoke without scope on today's path", async () => { + await withScopedParent(async (harness) => { + const invoke = harness.invoke("store_path", "plain-1"); + const write = await harness.nextHostCall(); + expect(write).toMatchObject({ toolName: "write", args: { path: "demo.txt", content: "body" } }); + harness.kernel.deliverToolReply({ type: "tool-reply", callId: write.callId, ok: true, value: "/tmp/demo.txt" }); + await expect(invoke).resolves.toBe("/tmp/demo.txt"); + }); + }); + + it("drops the scope when the call settles, so the next invoke runs unscoped", async () => { + await withScopedParent(async (harness) => { + await expect(harness.invoke("store_path", "scoped-1", scope({ deny: ["write"] }))).rejects.toMatchObject({ + code: "kernel_tool_host_denied", + }); + const invoke = harness.invoke("store_path", "unscoped-2"); + const write = await harness.nextHostCall(); + expect(write.toolName).toBe("write"); + harness.kernel.deliverToolReply({ type: "tool-reply", callId: write.callId, ok: true, value: "/tmp/demo.txt" }); + await expect(invoke).resolves.toBe("/tmp/demo.txt"); + }); + }); + + it("leaves the parent's own cell, queue and later cells unaffected by a refusal", async () => { + await withScopedParent(async (harness) => { + const queued = harness.kernel.run({ + cellId: "scope-parent-next", + code: "return await tool.write({ path: 'top-level.txt', content: 'x' });", + timeoutMs: 8_000, + }); + await expect(harness.invoke("store_path", "deny-3", scope({ deny: ["write"] }))).rejects.toMatchObject({ + code: "kernel_tool_host_denied", + }); + expect(harness.hostToolCalls).toEqual(["hold"]); + harness.kernel.deliverToolReply({ type: "tool-reply", callId: harness.hold.callId, ok: true, value: "held" }); + await expect(bounded(harness.parent, "parent cell")).resolves.toMatchObject({ + ok: true, + valueRepr: '"parent-done"', + }); + const topLevelWrite = await harness.nextHostCall(); + expect(topLevelWrite).toMatchObject({ toolName: "write", args: { path: "top-level.txt" } }); + harness.kernel.deliverToolReply({ + type: "tool-reply", + callId: topLevelWrite.callId, + ok: true, + value: "/tmp/top-level.txt", + }); + await expect(bounded(queued, "queued cell")).resolves.toMatchObject({ + ok: true, + valueRepr: '"/tmp/top-level.txt"', + }); + }); + }); +}); diff --git a/packages/senpi-codemode/test/kernel-tools-scope.test.ts b/packages/senpi-codemode/test/kernel-tools-scope.test.ts new file mode 100644 index 000000000..c18d9ffda --- /dev/null +++ b/packages/senpi-codemode/test/kernel-tools-scope.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { hostDeniedError, hostToolRefusal } from "../src/kernels/js/kernel-tools-scope.js"; + +/** Policy seam for https://github.com/code-yeongyu/senpi/issues/1731. */ +describe("kernel-tool call scope policy", () => { + it("permits every host tool when the call carries no tool scope", () => { + expect(hostToolRefusal(undefined, "write")).toBeNull(); + expect(hostToolRefusal({}, "write")).toBeNull(); + expect(hostToolRefusal({ tools: {} }, "write")).toBeNull(); + }); + + it("refuses only the denied host tools", () => { + const scope = { tools: { deny: ["write"] } }; + expect(hostToolRefusal(scope, "write")).toBe("deny"); + expect(hostToolRefusal(scope, "read")).toBeNull(); + }); + + it("refuses every host tool outside the allow list, including an empty allow list", () => { + expect(hostToolRefusal({ tools: { allow: ["read"] } }, "read")).toBeNull(); + expect(hostToolRefusal({ tools: { allow: ["read"] } }, "write")).toBe("allow"); + expect(hostToolRefusal({ tools: { allow: [] } }, "read")).toBe("allow"); + }); + + it("lets deny win over allow for the same host tool", () => { + const scope = { tools: { allow: ["read", "write"], deny: ["write"] } }; + expect(hostToolRefusal(scope, "write")).toBe("deny"); + expect(hostToolRefusal(scope, "read")).toBeNull(); + }); + + it("matches host tool names exactly", () => { + expect(hostToolRefusal({ tools: { deny: ["write"] } }, "Write")).toBeNull(); + expect(hostToolRefusal({ tools: { allow: ["read"] } }, "Read")).toBe("allow"); + }); + + it("builds a typed refusal carrying the tool, the invoking call id and the reason", () => { + const error = hostDeniedError("write", "call-7", "deny"); + expect(error.name).toBe("KernelToolError"); + expect(error.code).toBe("kernel_tool_host_denied"); + expect(error.details).toEqual({ tool: "write", call_id: "call-7", reason: "deny" }); + expect(error.message).toContain("write"); + }); +}); From e200aa063575c12f75feae42a0bf54f487bee0af Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 16 Sep 2026 23:17:37 +0900 Subject: [PATCH 2/3] feat(eval): call-scoped host-tool policy for kernel-tool invoke kernelTools.invoke now takes an optional per-call execution scope ({ scope: { tools: { allow, deny } } }) that the worker applies to the nested host calls the invoked closure makes. A call outside the scope is refused inside the worker with kernel_tool_host_denied carrying { tool, call_id, reason }, so the closure sees a rejected promise, the refusal never reaches the host bridge, and the parent's own cells and queue keep the parent's full tool surface. deny wins over allow, an allow list refuses every host tool it does not name, a malformed list fails closed, and the scope lives only for that call. A bare AbortSignal still works and an unscoped call posts the message it always did; consumers gate on kernelTools.capabilities.invokeScope. Fixes #1731 --- packages/senpi-codemode/CHANGELOG.md | 2 + packages/senpi-codemode/changes.md | 22 +++++ .../scripts/qa-kernel-tool-scope.ts | 96 +++++++++++++++++++ .../src/bridge/kernel-tools-protocol.ts | 19 ++++ packages/senpi-codemode/src/index.ts | 7 ++ .../src/kernels/js/context-manager.ts | 13 ++- .../src/kernels/js/kernel-tools-context.d.ts | 4 + .../src/kernels/js/kernel-tools-errors.js | 9 +- .../src/kernels/js/kernel-tools-errors.ts | 24 ++++- .../src/kernels/js/kernel-tools-host.ts | 47 ++++++++- .../src/kernels/js/kernel-tools-pump.js | 16 +++- .../src/kernels/js/kernel-tools-scope.d.ts | 9 ++ .../src/kernels/js/kernel-tools-scope.js | 34 +++++++ .../src/kernels/js/kernel-tools-types.ts | 33 ++++++- .../src/kernels/js/worker-core.js | 7 ++ .../senpi-codemode/src/kernels/py/kernel.ts | 3 +- .../src/kernels/shared/subprocess-kernel.ts | 3 +- .../senpi-codemode/src/tool/run-eval-cell.ts | 5 +- .../kernel-tools-invoke-scope-e2e.test.ts | 19 +++- .../test/kernel-tools-invoke-scope.test.ts | 14 ++- ...ope.test.ts => kernel-tools-scope.test.js} | 7 +- 21 files changed, 368 insertions(+), 25 deletions(-) create mode 100644 packages/senpi-codemode/scripts/qa-kernel-tool-scope.ts create mode 100644 packages/senpi-codemode/src/kernels/js/kernel-tools-scope.d.ts create mode 100644 packages/senpi-codemode/src/kernels/js/kernel-tools-scope.js rename packages/senpi-codemode/test/{kernel-tools-scope.test.ts => kernel-tools-scope.test.js} (83%) diff --git a/packages/senpi-codemode/CHANGELOG.md b/packages/senpi-codemode/CHANGELOG.md index 3df4e22d0..8eaffe86e 100644 --- a/packages/senpi-codemode/CHANGELOG.md +++ b/packages/senpi-codemode/CHANGELOG.md @@ -6,6 +6,8 @@ ### Added +- `kernelTools.invoke(request, options?)` accepts a per-call execution scope for the nested host calls the invoked closure makes: `{ scope: { tools: { allow?: string[], deny?: string[] } } }`. While that invocation is active, a `tool.()` outside the scope is refused inside the worker with `kernel_tool_host_denied` carrying `{ tool, call_id, reason: "allow" | "deny" }`: the closure sees a rejected promise, the refusal never reaches the host bridge, and the parent's own cells and queue keep the parent's full tool surface. `deny` wins over `allow`, an `allow` list refuses every host tool it does not name, a malformed list fails closed, and the scope lives only for that call — it is dropped when the call settles (including interrupt and reset) and is never persisted. The second argument still accepts a bare `AbortSignal`, and a call without a scope posts exactly the message it always did. Consumers detect the feature through `kernelTools.capabilities.invokeScope === true`; `KERNEL_TOOLS_CAPABILITIES`, `KernelToolsCapabilities`, `KernelToolsInvokeOptions`, `KernelToolsInvokeScope`, `KernelToolsHostScope`, `KernelToolHostDenial` and `KernelToolHostDenialReason` are exported ([#1731](https://github.com/code-yeongyu/senpi/issues/1731)). + ### Changed ### Fixed diff --git a/packages/senpi-codemode/changes.md b/packages/senpi-codemode/changes.md index d9ad80084..7aa73f81a 100644 --- a/packages/senpi-codemode/changes.md +++ b/packages/senpi-codemode/changes.md @@ -1,5 +1,27 @@ # senpi-codemode fork changes +## 2026-09-16 - Call-scoped host-tool policy for kernel-tool invoke (#1731) + +### What changed + +- `src/kernels/js/kernel-tools-types.ts` adds `KernelToolsInvokeScope`/`KernelToolsHostScope`/`KernelToolsInvokeOptions`, the `KERNEL_TOOLS_CAPABILITIES` marker (`invokeScope: true`) and widens `KernelToolsCapability.invoke` to `(request, options?: AbortSignal | KernelToolsInvokeOptions)`. +- `src/kernels/js/kernel-tools-host.ts` normalizes the second argument, copies the caller's lists onto the `kernel-tool-invoke` frame only when the call names host tools, and rebuilds the typed refusal (`kernel_tool_host_denied` plus its `details`) from the reply. +- `src/bridge/kernel-tools-protocol.ts` carries the optional `scope` on `kernel-tool-invoke` and the optional `details` payload on kernel-tool errors. +- `src/kernels/js/kernel-tools-scope.js` holds the policy (deny wins, allow list refuses everything it does not name, malformed list fails closed) and the refusal factory; `src/kernels/js/kernel-tools-pump.js` puts the scope in the call-scoped bridge store and serializes `details`; `src/kernels/js/worker-core.js` refuses a scoped nested host call before it reaches the bridge. +- `src/tool/run-eval-cell.ts` publishes `capabilities` on the cell's capability object and forwards the options through `JavaScriptKernel.invokeKernelTool`. + +### Why + +- A consumer granting a parent's kernel tool to a child with a narrower tool policy had only two options: refuse the grant, or let the closure's nested `tool.()` calls run with the parent's full permissions (#1731). The scope is per call, so the parent's own cells and queue are untouched. + +### Why an extension could not handle it + +- The refusal must happen inside the JS worker's call-scoped bridge context, between the closure and the host bridge, which only the codemode kernel owns. + +### Expected merge conflict zones + +- LOW: `src/kernels/js/kernel-tools-*`, `src/bridge/kernel-tools-protocol.ts`, `src/tool/run-eval-cell.ts`. + ## 2026-09-16 - Kernel-tool capability on the worker tool-call path (#1754) ### What changed diff --git a/packages/senpi-codemode/scripts/qa-kernel-tool-scope.ts b/packages/senpi-codemode/scripts/qa-kernel-tool-scope.ts new file mode 100644 index 000000000..ec592e7f2 --- /dev/null +++ b/packages/senpi-codemode/scripts/qa-kernel-tool-scope.ts @@ -0,0 +1,96 @@ +import type { KernelToHostMessage } from "../src/bridge/protocol.ts"; +import { JavaScriptKernel } from "../src/kernels/js/context-manager.ts"; +import type { KernelToolDescriptor, KernelToolsInvokeOptions } from "../src/kernels/js/kernel-tools-types.ts"; + +class QaFailure extends Error { + readonly name = "QaFailure"; +} + +/** The parent cell registers the closures a child would be granted, then parks on a host tool. */ +const PARENT_CELL = [ + "tool(async function fetch_path(path) { return await tool.read({ path }); });", + "tool(async function store_path(path) { return await tool.write({ path, content: 'body' }); });", + "await tool.hold({});", + "return 'parent-done';", +].join("\n"); + +function settled(error: unknown): { readonly code: string; readonly details: unknown } { + if (!(error instanceof Error)) return { code: "unknown", details: undefined }; + return { + code: "code" in error ? String(error.code) : "unknown", + details: "details" in error ? error.details : undefined, + }; +} + +async function main(): Promise { + const hostToolCalls: string[] = []; + const kernel = new JavaScriptKernel({ + sessionId: "qa-kernel-tool-scope", + cwd: process.cwd(), + parallelPoolWidth: 2, + onMessage: (message: KernelToHostMessage) => { + if (message.type === "tool-call") hostToolCalls.push(message.toolName); + }, + }); + try { + const parent = kernel.run({ cellId: "qa-scope-parent", code: PARENT_CELL, timeoutMs: 30_000 }); + const hold = await kernel.nextToolCall(); + if (hold.toolName !== "hold") throw new QaFailure(`parent cell parked on ${hold.toolName}`); + const described = await kernel.describeKernelTools(["fetch_path", "store_path"]); + const descriptors = new Map(); + for (const entry of described.results) { + if (!entry.ok) throw new QaFailure(`kernel tool descriptor missing: ${entry.name}`); + descriptors.set(entry.name, entry.descriptor); + } + const invoke = (name: string, callId: string, options: KernelToolsInvokeOptions): Promise => { + const descriptor = descriptors.get(name); + if (!descriptor) throw new QaFailure(`kernel tool descriptor missing: ${name}`); + return kernel.invokeKernelTool( + { + name: descriptor.name, + kernel_generation: descriptor.kernel_generation, + definition_revision: descriptor.definition_revision, + args: { path: "demo.txt" }, + call_id: callId, + }, + options, + ); + }; + + const denied = await invoke("store_path", "qa-denied", { scope: { tools: { deny: ["write"] } } }).then( + (value) => ({ settled: "resolved", value }), + (error: unknown) => ({ settled: "rejected", ...settled(error) }), + ); + console.log(`DENIED=${JSON.stringify(denied)}`); + + const allowed = invoke("fetch_path", "qa-allowed", { scope: { tools: { allow: ["read"], deny: ["write"] } } }); + const readCall = await kernel.nextToolCall(); + if (readCall.toolName !== "read") throw new QaFailure(`allowed nested call reached ${readCall.toolName}`); + kernel.deliverToolReply({ type: "tool-reply", callId: readCall.callId, ok: true, value: "nested-body" }); + console.log(`ALLOWED=${JSON.stringify(await allowed)}`); + + kernel.deliverToolReply({ type: "tool-reply", callId: hold.callId, ok: true, value: "held" }); + const parentResult = await parent; + console.log(`PARENT=${JSON.stringify({ ok: parentResult.ok, valueRepr: parentResult.valueRepr })}`); + console.log(`HOST_TOOL_CALLS=${JSON.stringify(hostToolCalls)}`); + + if (denied.settled !== "rejected" || denied.code !== "kernel_tool_host_denied") { + throw new QaFailure("denied nested host call did not fail closed"); + } + if (JSON.stringify(denied.details) !== JSON.stringify({ tool: "write", call_id: "qa-denied", reason: "deny" })) { + throw new QaFailure("refusal payload did not name the tool, call and reason"); + } + if (JSON.stringify(hostToolCalls) !== JSON.stringify(["hold", "read"])) { + throw new QaFailure(`denied nested call reached the host bridge: ${hostToolCalls.join(",")}`); + } + if (!parentResult.ok) throw new QaFailure("parent cell did not survive the refusal"); + console.log("\nQA PASS — scoped invoke refused write on its own channel, allowed read, parent cell unaffected"); + } finally { + await kernel.close(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/packages/senpi-codemode/src/bridge/kernel-tools-protocol.ts b/packages/senpi-codemode/src/bridge/kernel-tools-protocol.ts index e13fb98ef..57e4dfaf1 100644 --- a/packages/senpi-codemode/src/bridge/kernel-tools-protocol.ts +++ b/packages/senpi-codemode/src/bridge/kernel-tools-protocol.ts @@ -1,10 +1,28 @@ import { type Static, Type } from "typebox"; +/** Payload of a `kernel_tool_host_denied` refusal: the host tool, the invoking call, the reason (#1731). */ +const kernelToolHostDenialSchema = Type.Object({ + tool: Type.String({ minLength: 1 }), + call_id: Type.String({ minLength: 1 }), + reason: Type.Union([Type.Literal("allow"), Type.Literal("deny")]), +}); + const kernelToolErrorSchema = Type.Object({ message: Type.String(), name: Type.Optional(Type.String()), stack: Type.Optional(Type.String()), code: Type.Optional(Type.String()), + details: Type.Optional(kernelToolHostDenialSchema), +}); + +/** Per-call execution scope for the nested host calls the invoked closure makes (#1731). */ +export const kernelToolInvokeScopeSchema = Type.Object({ + tools: Type.Optional( + Type.Object({ + allow: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), + deny: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), + }), + ), }); export const kernelToolDescriptorSchema = Type.Object({ @@ -43,6 +61,7 @@ export const kernelToolHostToKernelSchemas = [ definition_revision: Type.Integer({ minimum: 1 }), args: Type.Unknown(), call_id: Type.String({ minLength: 1 }), + scope: Type.Optional(kernelToolInvokeScopeSchema), }), Type.Object({ type: Type.Literal("kernel-tool-cancel"), diff --git a/packages/senpi-codemode/src/index.ts b/packages/senpi-codemode/src/index.ts index 38ed4dde9..ea4b4dce9 100644 --- a/packages/senpi-codemode/src/index.ts +++ b/packages/senpi-codemode/src/index.ts @@ -263,10 +263,17 @@ function modelIdFrom(event: unknown): string | undefined { } export { + KERNEL_TOOLS_CAPABILITIES, KERNEL_TOOLS_UNSUPPORTED, type KernelToolDescriptor, + type KernelToolHostDenial, + type KernelToolHostDenialReason, + type KernelToolsCapabilities, type KernelToolsCapability, type KernelToolsDescribeResult, + type KernelToolsHostScope, + type KernelToolsInvokeOptions, type KernelToolsInvokeRequest, + type KernelToolsInvokeScope, } from "./kernels/js/kernel-tools-types.ts"; export { enabledLanguagesFrom }; diff --git a/packages/senpi-codemode/src/kernels/js/context-manager.ts b/packages/senpi-codemode/src/kernels/js/context-manager.ts index 0a4e2a8b0..24d3471bc 100644 --- a/packages/senpi-codemode/src/kernels/js/context-manager.ts +++ b/packages/senpi-codemode/src/kernels/js/context-manager.ts @@ -13,7 +13,11 @@ import { } from "./kernel-contract.ts"; import { kernelToolError } from "./kernel-tools-errors.ts"; import { KernelToolHostPump } from "./kernel-tools-host.ts"; -import type { KernelToolsDescribeResult, KernelToolsInvokeRequest } from "./kernel-tools-types.ts"; +import type { + KernelToolsDescribeResult, + KernelToolsInvokeOptions, + KernelToolsInvokeRequest, +} from "./kernel-tools-types.ts"; import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts"; import { terminateProcessTrees } from "./process-tree-host.ts"; import { JavaScriptRunQueue, type PendingJavaScriptRun, stoppedResult } from "./run-queue.ts"; @@ -72,8 +76,11 @@ export class JavaScriptKernel { return this.#kernelTools.describe(names); } - invokeKernelTool(request: KernelToolsInvokeRequest, signal?: AbortSignal): Promise { - return this.#kernelTools.invoke(request, signal); + invokeKernelTool( + request: KernelToolsInvokeRequest, + options?: AbortSignal | KernelToolsInvokeOptions, + ): Promise { + return this.#kernelTools.invoke(request, options); } async run(input: JavaScriptRunInput): Promise { diff --git a/packages/senpi-codemode/src/kernels/js/kernel-tools-context.d.ts b/packages/senpi-codemode/src/kernels/js/kernel-tools-context.d.ts index 3559780db..fe33d33c6 100644 --- a/packages/senpi-codemode/src/kernels/js/kernel-tools-context.d.ts +++ b/packages/senpi-codemode/src/kernels/js/kernel-tools-context.d.ts @@ -1,3 +1,5 @@ +import type { KernelToolsInvokeScope } from "./kernel-tools-types.ts"; + export const kernelToolCallContext: { run(store: KernelToolCallStore, fn: () => T): T; getStore(): KernelToolCallStore | undefined; @@ -8,6 +10,8 @@ export type KernelToolCallStore = { readonly callId: string; readonly generation: number; readonly signal: AbortSignal; + /** Host tools this call's nested bridge calls may reach; absent means the parent's full surface. */ + readonly scope?: KernelToolsInvokeScope; }; export function inKernelToolInvoke(): boolean; diff --git a/packages/senpi-codemode/src/kernels/js/kernel-tools-errors.js b/packages/senpi-codemode/src/kernels/js/kernel-tools-errors.js index fa5ce4ae6..213336d58 100644 --- a/packages/senpi-codemode/src/kernels/js/kernel-tools-errors.js +++ b/packages/senpi-codemode/src/kernels/js/kernel-tools-errors.js @@ -7,16 +7,19 @@ export const KERNEL_TOOL_ERROR_CODES = Object.freeze([ "kernel_tool_missing", "kernel_tool_failed", "kernel_tool_recursion", + "kernel_tool_host_denied", ]); export class KernelToolError extends Error { - constructor(code, message) { + constructor(code, message, details) { super(message); this.name = "KernelToolError"; this.code = code; + // Only `kernel_tool_host_denied` carries one today: { tool, call_id, reason }. + if (details !== undefined) this.details = details; } } -export function kernelToolError(code, message) { - return new KernelToolError(code, message); +export function kernelToolError(code, message, details) { + return new KernelToolError(code, message, details); } diff --git a/packages/senpi-codemode/src/kernels/js/kernel-tools-errors.ts b/packages/senpi-codemode/src/kernels/js/kernel-tools-errors.ts index 8a0bfaaa3..f89c82e79 100644 --- a/packages/senpi-codemode/src/kernels/js/kernel-tools-errors.ts +++ b/packages/senpi-codemode/src/kernels/js/kernel-tools-errors.ts @@ -7,20 +7,38 @@ export const KERNEL_TOOL_ERROR_CODES = [ "kernel_tool_missing", "kernel_tool_failed", "kernel_tool_recursion", + "kernel_tool_host_denied", ] as const; export type KernelToolErrorCode = (typeof KERNEL_TOOL_ERROR_CODES)[number]; +/** Why a nested host call was refused: it is outside the call's allow list, or named by its deny list. */ +export type KernelToolHostDenialReason = "allow" | "deny"; + +/** Payload carried by `kernel_tool_host_denied`: the host tool, the invoking kernel-tool call, the reason. */ +export type KernelToolHostDenial = { + readonly tool: string; + readonly call_id: string; + readonly reason: KernelToolHostDenialReason; +}; + export class KernelToolError extends Error { readonly name = "KernelToolError"; readonly code: KernelToolErrorCode; + /** Structured payload for the codes that carry one; only `kernel_tool_host_denied` does today. */ + readonly details?: KernelToolHostDenial; - constructor(code: KernelToolErrorCode, message: string) { + constructor(code: KernelToolErrorCode, message: string, details?: KernelToolHostDenial) { super(message); this.code = code; + if (details !== undefined) this.details = details; } } -export function kernelToolError(code: KernelToolErrorCode, message: string): KernelToolError { - return new KernelToolError(code, message); +export function kernelToolError( + code: KernelToolErrorCode, + message: string, + details?: KernelToolHostDenial, +): KernelToolError { + return new KernelToolError(code, message, details); } diff --git a/packages/senpi-codemode/src/kernels/js/kernel-tools-host.ts b/packages/senpi-codemode/src/kernels/js/kernel-tools-host.ts index a69e448e0..d483ff64c 100644 --- a/packages/senpi-codemode/src/kernels/js/kernel-tools-host.ts +++ b/packages/senpi-codemode/src/kernels/js/kernel-tools-host.ts @@ -2,7 +2,12 @@ import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/prot import { generateCorrelationId } from "../../bridge/protocol.ts"; import { RESERVED_AGENT_TOOL } from "../../bridge/reserved.ts"; import { kernelToolError } from "./kernel-tools-errors.ts"; -import type { KernelToolsDescribeResult, KernelToolsInvokeRequest } from "./kernel-tools-types.ts"; +import type { + KernelToolsDescribeResult, + KernelToolsInvokeOptions, + KernelToolsInvokeRequest, + KernelToolsInvokeScope, +} from "./kernel-tools-types.ts"; type KernelToolReply = Extract< KernelToHostMessage, @@ -59,7 +64,13 @@ export class KernelToolHostPump { return { results: reply.results as KernelToolsDescribeResult["results"] }; } - async invoke(request: KernelToolsInvokeRequest, signal?: AbortSignal): Promise { + /** + * `options` is the caller's abort signal, or `{ signal?, scope? }` where `scope` bounds the host + * tools the invoked closure may reach during this call only. A call without a scope posts exactly + * the message it always did (#1731). + */ + async invoke(request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions): Promise { + const { signal, scope } = normalizeInvokeOptions(options); this.events.dispatchEvent(new Event("nestedInvoke")); const reply = await this.#request( { @@ -70,13 +81,14 @@ export class KernelToolHostPump { definition_revision: request.definition_revision, args: request.args, call_id: request.call_id, + ...wireScope(scope), }, signal, ); if (reply.type !== "kernel-tool-invoke-reply") { throw kernelToolError("kernel_tool_failed", "unexpected kernel-tool invoke reply"); } - if (!reply.ok) throw kernelToolError(codeOf(reply.error.code), reply.error.message); + if (!reply.ok) throw kernelToolError(codeOf(reply.error.code), reply.error.message, reply.error.details); return reply.value; } @@ -112,6 +124,33 @@ export class KernelToolHostPump { } } +/** + * The scope as the protocol carries it: own copies of the caller's lists, and nothing at all when the + * caller named no host tools, so an unscoped call posts exactly the message it always did. + */ +function wireScope(scope?: KernelToolsInvokeScope): { scope?: { tools: { allow?: string[]; deny?: string[] } } } { + const tools = scope?.tools; + if (tools === undefined) return {}; + if (tools.allow === undefined && tools.deny === undefined) return {}; + return { + scope: { + tools: { + ...(tools.allow === undefined ? {} : { allow: [...tools.allow] }), + ...(tools.deny === undefined ? {} : { deny: [...tools.deny] }), + }, + }, + }; +} + +function normalizeInvokeOptions(options?: AbortSignal | KernelToolsInvokeOptions): KernelToolsInvokeOptions { + if (options === undefined) return {}; + return isAbortSignal(options) ? { signal: options } : options; +} + +function isAbortSignal(options: AbortSignal | KernelToolsInvokeOptions): options is AbortSignal { + return options instanceof AbortSignal || "aborted" in options; +} + function codeOf( code: string | undefined, ): @@ -119,12 +158,14 @@ function codeOf( | "kernel_tool_stale" | "kernel_tool_missing" | "kernel_tool_recursion" + | "kernel_tool_host_denied" | "tools_unavailable" | "invalid_tool_definition" { if ( code === "kernel_tool_stale" || code === "kernel_tool_missing" || code === "kernel_tool_recursion" || + code === "kernel_tool_host_denied" || code === "tools_unavailable" || code === "invalid_tool_definition" ) { diff --git a/packages/senpi-codemode/src/kernels/js/kernel-tools-pump.js b/packages/senpi-codemode/src/kernels/js/kernel-tools-pump.js index c35ab19c0..63e40417b 100644 --- a/packages/senpi-codemode/src/kernels/js/kernel-tools-pump.js +++ b/packages/senpi-codemode/src/kernels/js/kernel-tools-pump.js @@ -21,7 +21,15 @@ export function createKernelToolPump({ getRuntime, emit, nestedInvokes }) { const runtime = getRuntime(); if (!runtime?.kernelTools) throw kernelToolError("tools_unavailable", "JS runtime not initialized"); const value = await kernelToolCallContext.run( - { pendingTools, callId: message.call_id, generation: message.kernel_generation, signal: controller.signal }, + // The scope lives only in this store: it is gone once the call settles, and it never + // reaches the top-level cell context or the next invocation (#1731). + { + pendingTools, + callId: message.call_id, + generation: message.kernel_generation, + signal: controller.signal, + scope: message.scope, + }, () => runtime.kernelTools.invoke( { @@ -91,15 +99,21 @@ function pumpError(error) { message: error.message, stack: error.stack, ...(typeof error.code === "string" ? { code: error.code } : {}), + ...(isPlainRecord(error.details) ? { details: error.details } : {}), }; } return { message: String(error) }; } +function isPlainRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + function errorFrom(error) { const result = new Error(error.message); if (error.name) result.name = error.name; if (error.stack) result.stack = error.stack; if (typeof error.code === "string") result.code = error.code; + if (isPlainRecord(error.details)) result.details = error.details; return result; } diff --git a/packages/senpi-codemode/src/kernels/js/kernel-tools-scope.d.ts b/packages/senpi-codemode/src/kernels/js/kernel-tools-scope.d.ts new file mode 100644 index 000000000..62f499cfd --- /dev/null +++ b/packages/senpi-codemode/src/kernels/js/kernel-tools-scope.d.ts @@ -0,0 +1,9 @@ +import type { KernelToolError, KernelToolHostDenialReason } from "./kernel-tools-errors.ts"; +import type { KernelToolsInvokeScope } from "./kernel-tools-types.ts"; + +export function hostToolRefusal( + scope: KernelToolsInvokeScope | undefined, + toolName: string, +): KernelToolHostDenialReason | null; + +export function hostDeniedError(toolName: string, callId: string, reason: KernelToolHostDenialReason): KernelToolError; diff --git a/packages/senpi-codemode/src/kernels/js/kernel-tools-scope.js b/packages/senpi-codemode/src/kernels/js/kernel-tools-scope.js new file mode 100644 index 000000000..16c46f2c7 --- /dev/null +++ b/packages/senpi-codemode/src/kernels/js/kernel-tools-scope.js @@ -0,0 +1,34 @@ +import { kernelToolError } from "./kernel-tools-errors.js"; + +/** + * Call-scoped host-tool policy for one kernel-tool invocation (#1731). A call without a tool scope + * refuses nothing (today's behavior); `deny` wins over `allow`; an `allow` list refuses every host + * tool it does not name; a malformed list fails closed instead of widening the call's reach. + */ +export function hostToolRefusal(scope, toolName) { + const tools = scope?.tools; + if (tools === null || typeof tools !== "object") return null; + if (tools.deny !== undefined) { + const deny = nameList(tools.deny); + if (deny === null || deny.includes(toolName)) return "deny"; + } + if (tools.allow !== undefined) { + const allow = nameList(tools.allow); + if (allow === null || !allow.includes(toolName)) return "allow"; + } + return null; +} + +/** The typed refusal the closure sees and the invoking call reports. */ +export function hostDeniedError(toolName, callId, reason) { + return kernelToolError( + "kernel_tool_host_denied", + `Host tool is outside this kernel tool call's scope: ${toolName} (${reason})`, + { tool: toolName, call_id: callId, reason }, + ); +} + +function nameList(value) { + if (!Array.isArray(value)) return null; + return value.every((name) => typeof name === "string") ? value : null; +} diff --git a/packages/senpi-codemode/src/kernels/js/kernel-tools-types.ts b/packages/senpi-codemode/src/kernels/js/kernel-tools-types.ts index 433c8cda9..6b2cf2140 100644 --- a/packages/senpi-codemode/src/kernels/js/kernel-tools-types.ts +++ b/packages/senpi-codemode/src/kernels/js/kernel-tools-types.ts @@ -1,6 +1,6 @@ -import type { KernelToolErrorCode } from "./kernel-tools-errors.ts"; +import type { KernelToolErrorCode, KernelToolHostDenial, KernelToolHostDenialReason } from "./kernel-tools-errors.ts"; -export type { KernelToolErrorCode }; +export type { KernelToolErrorCode, KernelToolHostDenial, KernelToolHostDenialReason }; export type KernelToolDescriptor = { readonly name: string; @@ -31,9 +31,36 @@ export type KernelToolsDescribeResult = { readonly results: readonly KernelToolsDescribeEntry[]; }; +/** + * Host tools a kernel-tool invocation's nested calls may reach: `allow` narrows to exactly those + * names, `deny` refuses the named ones, and `deny` wins where both name the same tool. + */ +export type KernelToolsHostScope = { + readonly allow?: readonly string[]; + readonly deny?: readonly string[]; +}; + +/** Execution scope for one `invoke`; never persisted, dropped when that call settles (#1731). */ +export type KernelToolsInvokeScope = { + readonly tools?: KernelToolsHostScope; +}; + +export type KernelToolsInvokeOptions = { + readonly signal?: AbortSignal; + readonly scope?: KernelToolsInvokeScope; +}; + +/** Stable capability markers a consumer gates on before sending an option this runtime may not know. */ +export type KernelToolsCapabilities = { + readonly invokeScope: true; +}; + +export const KERNEL_TOOLS_CAPABILITIES: KernelToolsCapabilities = Object.freeze({ invokeScope: true }); + export type KernelToolsCapability = { + readonly capabilities: KernelToolsCapabilities; describe(names: readonly string[]): Promise; - invoke(request: KernelToolsInvokeRequest, signal?: AbortSignal): Promise; + invoke(request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions): Promise; }; export const KERNEL_TOOLS_UNSUPPORTED = { diff --git a/packages/senpi-codemode/src/kernels/js/worker-core.js b/packages/senpi-codemode/src/kernels/js/worker-core.js index 8d1f60e46..dd396d1c0 100644 --- a/packages/senpi-codemode/src/kernels/js/worker-core.js +++ b/packages/senpi-codemode/src/kernels/js/worker-core.js @@ -1,6 +1,7 @@ import { kernelToolCallContext } from "./kernel-tools-context.js"; import { kernelToolError } from "./kernel-tools-errors.js"; import { createKernelToolPump } from "./kernel-tools-pump.js"; +import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js"; import { JsWorkerRuntime } from "./worker-runtime.js"; // Mirrors INTERRUPT_ACK_OP and CHILD_LIFECYCLE_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript). @@ -59,6 +60,12 @@ export function createWorkerCore(transport, options) { const nested = kernelToolCallContext.getStore(); if (!nested && activeCell?.interruption) throw activeCell.interruption; if (nested?.signal.aborted) throw nested.signal.reason; + // A scoped kernel-tool call is refused here, before anything reaches the host bridge, so the + // closure sees a rejected promise and the parent's own cells keep their full tool surface (#1731). + if (nested) { + const refusal = hostToolRefusal(nested.scope, toolName); + if (refusal) throw hostDeniedError(toolName, nested.callId, refusal); + } const bag = nested?.pendingTools ?? pendingTools; const callId = `js-${crypto.randomUUID()}`; const promise = new Promise((resolve, reject) => bag.set(callId, { resolve, reject })); diff --git a/packages/senpi-codemode/src/kernels/py/kernel.ts b/packages/senpi-codemode/src/kernels/py/kernel.ts index 1ad6dedc9..59e9a2eff 100644 --- a/packages/senpi-codemode/src/kernels/py/kernel.ts +++ b/packages/senpi-codemode/src/kernels/py/kernel.ts @@ -1,4 +1,5 @@ import type { KernelInterruptHandle } from "../../tool/types.ts"; +import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts"; import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts"; import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts"; import { failedPythonResult, PythonKernelTransport } from "./transport.ts"; @@ -40,7 +41,7 @@ export class PythonKernel { return rejectKernelToolsUnavailable(); } - invokeKernelTool(_request: unknown, _signal?: AbortSignal): Promise { + invokeKernelTool(_request: unknown, _options?: AbortSignal | KernelToolsInvokeOptions): Promise { return rejectKernelToolsUnavailable(); } diff --git a/packages/senpi-codemode/src/kernels/shared/subprocess-kernel.ts b/packages/senpi-codemode/src/kernels/shared/subprocess-kernel.ts index 6931353e9..7f5f5935d 100644 --- a/packages/senpi-codemode/src/kernels/shared/subprocess-kernel.ts +++ b/packages/senpi-codemode/src/kernels/shared/subprocess-kernel.ts @@ -1,6 +1,7 @@ import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts"; import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts"; import type { KernelInterruptHandle } from "../../tool/types.ts"; +import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts"; import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts"; import { applySessionEnvironment } from "../session-env.ts"; import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts"; @@ -78,7 +79,7 @@ export class SubprocessKernel { return rejectKernelToolsUnavailable(); } - invokeKernelTool(_request: unknown, _signal?: AbortSignal): Promise { + invokeKernelTool(_request: unknown, _options?: AbortSignal | KernelToolsInvokeOptions): Promise { return rejectKernelToolsUnavailable(); } diff --git a/packages/senpi-codemode/src/tool/run-eval-cell.ts b/packages/senpi-codemode/src/tool/run-eval-cell.ts index cad8d797d..bbbe1c273 100644 --- a/packages/senpi-codemode/src/tool/run-eval-cell.ts +++ b/packages/senpi-codemode/src/tool/run-eval-cell.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { type AgentToolResult, type ExtensionContext, kernelToolsStorage } from "@code-yeongyu/senpi"; import { DEFAULT_FOREGROUND_WINDOW_SECONDS, defaultCodemodeSettings } from "../config/settings.ts"; -import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts"; +import { KERNEL_TOOLS_CAPABILITIES, type KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts"; import { TIMEOUT_PAUSE_OP, TIMEOUT_RESUME_OP } from "../timeouts/bridge-timeout.ts"; import { abortError, CellExecution, defaultTimeoutFactory } from "./cell-execution.ts"; import { CellHandler, type CellState } from "./cell-handler.ts"; @@ -224,7 +224,8 @@ function jsKernelTools(kernel: EvalKernel, language: string): KernelToolsCapabil invokeKernelTool: KernelToolsCapability["invoke"]; }; return { + capabilities: KERNEL_TOOLS_CAPABILITIES, describe: (names) => js.describeKernelTools(names), - invoke: (request, signal) => js.invokeKernelTool(request, signal), + invoke: (request, options) => js.invokeKernelTool(request, options), }; } diff --git a/packages/senpi-codemode/test/kernel-tools-invoke-scope-e2e.test.ts b/packages/senpi-codemode/test/kernel-tools-invoke-scope-e2e.test.ts index 45792aed0..cc098ce46 100644 --- a/packages/senpi-codemode/test/kernel-tools-invoke-scope-e2e.test.ts +++ b/packages/senpi-codemode/test/kernel-tools-invoke-scope-e2e.test.ts @@ -4,7 +4,13 @@ import type { KernelToHostMessage } from "../src/bridge/protocol.ts"; import { JavaScriptKernel } from "../src/kernels/js/context-manager.ts"; import type { KernelToolsCapability } from "../src/kernels/js/kernel-tools-types.ts"; import { createEvalTool } from "../src/tool/eval-tool.ts"; -import type { EvalKernel, EvalKernelManager, EvalLanguage, EvalToolDetails } from "../src/tool/types.ts"; +import type { + EvalKernel, + EvalKernelManager, + EvalLanguage, + EvalToolCallSummary, + EvalToolDetails, +} from "../src/tool/types.ts"; import { fakeExtensionContext } from "./eval/fakes.ts"; type ProbeArgs = { readonly path: string }; @@ -58,6 +64,10 @@ function outputText(cell: AgentToolResult): string { return part?.type === "text" ? part.text : ""; } +function byName(left: { readonly name: string }, right: { readonly name: string }): number { + return left.name.localeCompare(right.name); +} + function deniedRecord(error: unknown): DeniedRecord { if (!(error instanceof Error)) return { code: "unknown" }; return { @@ -182,7 +192,12 @@ describe("kernel-tool invoke scope on the real worker tool-call path", () => { ]); expect(hostCalls).toEqual(["probe", "read"]); expect(cell.details.isError).toBeFalsy(); - expect(cell.details.toolCalls[0]).toMatchObject({ name: "probe", ok: true }); + // The nested read settles before the probe that drove it, so compare the captured set, not order. + const captured = cell.details.toolCalls.map((call: EvalToolCallSummary) => ({ name: call.name, ok: call.ok })); + expect(captured.sort(byName)).toEqual([ + { name: "probe", ok: true }, + { name: "read", ok: true }, + ]); expect(outputText(cell)).toContain("kernel_tool_host_denied"); expect(ctx.kernelTools).toBeUndefined(); }); diff --git a/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts b/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts index 69025438d..d1380564c 100644 --- a/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts +++ b/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts @@ -160,7 +160,12 @@ describe("kernel-tool invoke scope", () => { const invoke = harness.invoke("store_path", "plain-1"); const write = await harness.nextHostCall(); expect(write).toMatchObject({ toolName: "write", args: { path: "demo.txt", content: "body" } }); - harness.kernel.deliverToolReply({ type: "tool-reply", callId: write.callId, ok: true, value: "/tmp/demo.txt" }); + harness.kernel.deliverToolReply({ + type: "tool-reply", + callId: write.callId, + ok: true, + value: "/tmp/demo.txt", + }); await expect(invoke).resolves.toBe("/tmp/demo.txt"); }); }); @@ -173,7 +178,12 @@ describe("kernel-tool invoke scope", () => { const invoke = harness.invoke("store_path", "unscoped-2"); const write = await harness.nextHostCall(); expect(write.toolName).toBe("write"); - harness.kernel.deliverToolReply({ type: "tool-reply", callId: write.callId, ok: true, value: "/tmp/demo.txt" }); + harness.kernel.deliverToolReply({ + type: "tool-reply", + callId: write.callId, + ok: true, + value: "/tmp/demo.txt", + }); await expect(invoke).resolves.toBe("/tmp/demo.txt"); }); }); diff --git a/packages/senpi-codemode/test/kernel-tools-scope.test.ts b/packages/senpi-codemode/test/kernel-tools-scope.test.js similarity index 83% rename from packages/senpi-codemode/test/kernel-tools-scope.test.ts rename to packages/senpi-codemode/test/kernel-tools-scope.test.js index c18d9ffda..173ef6791 100644 --- a/packages/senpi-codemode/test/kernel-tools-scope.test.ts +++ b/packages/senpi-codemode/test/kernel-tools-scope.test.js @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { hostDeniedError, hostToolRefusal } from "../src/kernels/js/kernel-tools-scope.js"; -/** Policy seam for https://github.com/code-yeongyu/senpi/issues/1731. */ +/** Call-scoped host-tool policy for kernel-tool invoke (https://github.com/code-yeongyu/senpi/issues/1731). */ describe("kernel-tool call scope policy", () => { it("permits every host tool when the call carries no tool scope", () => { expect(hostToolRefusal(undefined, "write")).toBeNull(); @@ -32,6 +32,11 @@ describe("kernel-tool call scope policy", () => { expect(hostToolRefusal({ tools: { allow: ["read"] } }, "Read")).toBe("allow"); }); + it("fails closed on a malformed list instead of widening the scope", () => { + expect(hostToolRefusal({ tools: { deny: "write" } }, "read")).toBe("deny"); + expect(hostToolRefusal({ tools: { allow: "read" } }, "read")).toBe("allow"); + }); + it("builds a typed refusal carrying the tool, the invoking call id and the reason", () => { const error = hostDeniedError("write", "call-7", "deny"); expect(error.name).toBe("KernelToolError"); From 3ea9e79535585a3cb998c532a74a514d2fad5b4c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 16 Sep 2026 23:21:45 +0900 Subject: [PATCH 3/3] test(eval): scoped nested wait settles once on parent interrupt --- .../test/kernel-tools-invoke-scope.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts b/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts index d1380564c..6b8d0d7ea 100644 --- a/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts +++ b/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts @@ -188,6 +188,38 @@ describe("kernel-tool invoke scope", () => { }); }); + it("settles a scoped nested wait exactly once when the parent is interrupted", async () => { + await withScopedParent(async (harness) => { + const invoke = harness.invoke("fetch_path", "interrupt-1", scope({ allow: ["read"] })); + const read = await harness.nextHostCall(); + expect(read.toolName).toBe("read"); + let settles = 0; + const tracked = invoke.then( + (value) => { + settles += 1; + return { ok: true as const, value }; + }, + (error: unknown) => { + settles += 1; + return { + ok: false as const, + code: error instanceof Error && "code" in error ? String(error.code) : "unknown", + }; + }, + ); + await harness.kernel.interrupt("verify-scoped-interrupt"); + await expect(bounded(harness.parent, "parent cell")).resolves.toMatchObject({ + ok: false, + error: { message: "JS cell interrupted: verify-scoped-interrupt" }, + }); + await expect(bounded(tracked, "scoped nested invoke")).resolves.toMatchObject({ + ok: false, + code: "kernel_tool_stale", + }); + expect(settles).toBe(1); + }); + }); + it("leaves the parent's own cell, queue and later cells unaffected by a refusal", async () => { await withScopedParent(async (harness) => { const queued = harness.kernel.run({