Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/senpi-codemode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>()` 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
Expand Down
22 changes: 22 additions & 0 deletions packages/senpi-codemode/changes.md
Original file line number Diff line number Diff line change
@@ -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.<host>()` 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
Expand Down
96 changes: 96 additions & 0 deletions packages/senpi-codemode/scripts/qa-kernel-tool-scope.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, KernelToolDescriptor>();
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<unknown> => {
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;
});
19 changes: 19 additions & 0 deletions packages/senpi-codemode/src/bridge/kernel-tools-protocol.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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"),
Expand Down
7 changes: 7 additions & 0 deletions packages/senpi-codemode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
13 changes: 10 additions & 3 deletions packages/senpi-codemode/src/kernels/js/context-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -72,8 +76,11 @@ export class JavaScriptKernel {
return this.#kernelTools.describe(names);
}

invokeKernelTool(request: KernelToolsInvokeRequest, signal?: AbortSignal): Promise<unknown> {
return this.#kernelTools.invoke(request, signal);
invokeKernelTool(
request: KernelToolsInvokeRequest,
options?: AbortSignal | KernelToolsInvokeOptions,
): Promise<unknown> {
return this.#kernelTools.invoke(request, options);
}

async run(input: JavaScriptRunInput): Promise<ResultMessage> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { KernelToolsInvokeScope } from "./kernel-tools-types.ts";

export const kernelToolCallContext: {
run<T>(store: KernelToolCallStore, fn: () => T): T;
getStore(): KernelToolCallStore | undefined;
Expand All @@ -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;
9 changes: 6 additions & 3 deletions packages/senpi-codemode/src/kernels/js/kernel-tools-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
24 changes: 21 additions & 3 deletions packages/senpi-codemode/src/kernels/js/kernel-tools-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
47 changes: 44 additions & 3 deletions packages/senpi-codemode/src/kernels/js/kernel-tools-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,7 +64,13 @@ export class KernelToolHostPump {
return { results: reply.results as KernelToolsDescribeResult["results"] };
}

async invoke(request: KernelToolsInvokeRequest, signal?: AbortSignal): Promise<unknown> {
/**
* `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<unknown> {
const { signal, scope } = normalizeInvokeOptions(options);
this.events.dispatchEvent(new Event("nestedInvoke"));
const reply = await this.#request(
{
Expand All @@ -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;
}

Expand Down Expand Up @@ -112,19 +124,48 @@ 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,
):
| "kernel_tool_failed"
| "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"
) {
Expand Down
Loading