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 new file mode 100644 index 000000000..cc098ce46 --- /dev/null +++ b/packages/senpi-codemode/test/kernel-tools-invoke-scope-e2e.test.ts @@ -0,0 +1,204 @@ +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, + EvalToolCallSummary, + 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 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 { + ...("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(); + // 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 new file mode 100644 index 000000000..6b8d0d7ea --- /dev/null +++ b/packages/senpi-codemode/test/kernel-tools-invoke-scope.test.ts @@ -0,0 +1,253 @@ +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("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({ + 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.js b/packages/senpi-codemode/test/kernel-tools-scope.test.js new file mode 100644 index 000000000..173ef6791 --- /dev/null +++ b/packages/senpi-codemode/test/kernel-tools-scope.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { hostDeniedError, hostToolRefusal } from "../src/kernels/js/kernel-tools-scope.js"; + +/** 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(); + 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("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"); + 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"); + }); +});