From eb7893201da09d82e9aaf29bba984f0415bca67e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 27 Oct 2026 00:12:22 +0900 Subject: [PATCH 1/6] feat(senpi-codemode): bind kernel tool invokers --- changes/unreleased/4309.md | 78 ++++++++++++ .../core/extensions/kernel-tools-context.ts | 5 + .../src/core/extensions/runner.ts | 18 ++- .../src/core/extensions/wrapper.ts | 5 +- .../src/core/tool-dispatch/tool-dispatcher.ts | 5 +- .../src/kernels/js/context-manager.ts | 8 +- .../src/kernels/js/kernel-contract.ts | 4 +- .../src/kernels/js/permission-core.md | 24 +++- .../src/kernels/js/permission-host.ts | 71 +++++++++-- .../src/kernels/py/kernel-contract.ts | 2 + .../senpi-codemode/src/kernels/py/kernel.ts | 29 ++++- .../kernels/shared/invocation-authority.ts | 56 +++++++++ .../src/kernels/shared/kernel-tools-pump.ts | 23 +++- .../test/kernel-invocation-token.test.ts | 111 ++++++++++++++++++ .../kernel-tool-invoker-authority.test.ts | 111 ++++++++++++++++++ .../test/py-kernel-invoker-authority.test.ts | 55 +++++++++ 16 files changed, 574 insertions(+), 31 deletions(-) create mode 100644 changes/unreleased/4309.md create mode 100644 packages/senpi-codemode/src/kernels/shared/invocation-authority.ts create mode 100644 packages/senpi-codemode/test/kernel-invocation-token.test.ts create mode 100644 packages/senpi-codemode/test/kernel-tool-invoker-authority.test.ts create mode 100644 packages/senpi-codemode/test/py-kernel-invoker-authority.test.ts diff --git a/changes/unreleased/4309.md b/changes/unreleased/4309.md new file mode 100644 index 0000000000..9dc1d7f2d6 --- /dev/null +++ b/changes/unreleased/4309.md @@ -0,0 +1,78 @@ + +## 2026-10-26 - Host-bound kernel-tool invoker identity (part of senpi#4309) + +### What changed + +- `packages/coding-agent/src/core/extensions/kernel-tools-context.ts`: add host-only invocation identity. +- `packages/coding-agent/src/core/extensions/runner.ts`: stamp the invoking SessionManager's identity after every caller-supplied option. +- `packages/coding-agent/src/core/extensions/wrapper.ts`: bind dispatcher-free tool calls to their host context. + +### Why + +- `packages/coding-agent/src/core/extensions/kernel-tools-context.ts`: cross-session kernel permissions need a host-authenticated caller. +- `packages/coding-agent/src/core/extensions/runner.ts`: cell arguments and inherited capabilities must not select the permission session. +- `packages/coding-agent/src/core/extensions/wrapper.ts`: a capability captured in a parent still runs with the child's host identity. + +### Why an extension could not handle it + +- `packages/coding-agent/src/core/extensions/kernel-tools-context.ts`: this contract crosses the dispatcher and kernel host. +- `packages/coding-agent/src/core/extensions/runner.ts`: only the dispatcher owns the calling session's SessionManager. +- `packages/coding-agent/src/core/extensions/wrapper.ts`: the host wrapper owns the fallback execution boundary. + +### Expected merge conflict zones + +- `packages/coding-agent/src/core/extensions/kernel-tools-context.ts`: invocation options. +- `packages/coding-agent/src/core/extensions/runner.ts`: the lazy kernelTools context getter. +- `packages/coding-agent/src/core/extensions/wrapper.ts`: registered tool execution. + + +## 2026-10-26 - Bind kernel invoker authority at tool dispatch (part of senpi#4309) + +### What changed + +- `packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts`: bind the calling session's identity in host async context around server tool execution. + +### Why + +- `packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts`: inherited parent capabilities must use the child's dispatch identity, not the session that created the capability. + +### Why an extension could not handle it + +- `packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts`: the dispatcher has the actual calling SessionManager; tool arguments are untrusted. + +### Expected merge conflict zones + +- `packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts`: BaseToolDispatcher.runOnServer. + + +## 2026-10-26 - Cross-session kernel-tool permission authority (part of senpi#4309) + +### What changed + +- `packages/senpi-codemode/src/kernels/js/permission-host.ts`, `kernel-contract.ts`, and `context-manager.ts`: mint invoker tokens and reject cross-session invocations before dispatch when either session enforces. +- `packages/senpi-codemode/src/kernels/shared/invocation-authority.ts` and `kernel-tools-pump.ts`: resolve host-only session snapshots, forward invocation options into the host hook, and release authority on settlement. +- `packages/senpi-codemode/src/kernels/py/kernel.ts` and `kernel-contract.ts`: apply the same refusal before Python tool bodies execute. +- `packages/senpi-codemode/src/kernels/js/permission-core.md`: document the integration resolver for invoking sessions without kernels. + +### Why + +- Kernel tools retained the owning session's authority even when another session invoked them. Either session's deny rules must prevent sharing retained kernel resources. + +### Why an extension could not handle it + +- Caller identity and invocation admission belong to the dispatcher, shared pump, and kernel host; cell code cannot authenticate its own caller. + +### Expected merge conflict zones + +- Kernel-tool invocation, permission token minting, Python startup/close, and the shared host pump. +- The real permission-system registry is connected by the integration PR through the typed session resolver; this PR remains stacked on the JS permission core. +- Known limitation: reads performed by the runtime's native module resolver (bare packages, tsconfig aliases, package.json #imports, symlinked packages) are not covered by this work; tracked in #4380. + + +### Added +- Bind kernel-tool invocation identity to the dispatcher's own session rather than tool arguments (part of #4309). + + +### Added +- Refuse cross-session JavaScript and Python kernel tools before their bodies run when either session enforces a deny rule, and mint invoker-scoped JS tokens when neither enforces (part of #4309). +- Known limitation: reads performed by the runtime's native module resolver (bare packages, tsconfig aliases, package.json #imports, symlinked packages) are not covered by this work; tracked in #4380. diff --git a/packages/coding-agent/src/core/extensions/kernel-tools-context.ts b/packages/coding-agent/src/core/extensions/kernel-tools-context.ts index 26b6715bc8..fb8745ad71 100644 --- a/packages/coding-agent/src/core/extensions/kernel-tools-context.ts +++ b/packages/coding-agent/src/core/extensions/kernel-tools-context.ts @@ -11,6 +11,8 @@ export type KernelToolInvokeScope = { export type KernelToolInvokeOptions = { signal?: AbortSignal; scope?: KernelToolInvokeScope; + /** Host dispatcher identity, never read from a tool's arguments or cell data. */ + invoker?: { readonly sessionId: string }; }; export type ExtensionKernelTools = { @@ -31,3 +33,6 @@ export type ExtensionKernelTools = { }; export const kernelToolsStorage = new AsyncLocalStorage(); + +/** The active host dispatcher, including when it executes a capability captured in another session. */ +export const kernelToolInvokerStorage = new AsyncLocalStorage<{ readonly sessionId: string }>(); diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index b38034b5d8..19d8559205 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -45,7 +45,7 @@ import { runBudgetedHandler, runBudgetedSequence, } from "./handler-budget.ts"; -import { kernelToolsStorage } from "./kernel-tools-context.ts"; +import { type ExtensionKernelTools, kernelToolInvokerStorage, kernelToolsStorage } from "./kernel-tools-context.ts"; import { drainPendingProviderRegistrations } from "./loader.ts"; import { runSessionMoveHandlers, type SessionMoveEmission, type SessionMoveFailure } from "./session-move-emit.ts"; import { SessionStartTurnGate } from "./session-start-turn-gate.ts"; @@ -1578,7 +1578,21 @@ export class ExtensionRunner { }, get kernelTools() { runner.assertActive(); - return kernelToolsStorage.getStore(); + const tools = kernelToolsStorage.getStore(); + if (!tools) return undefined; + return { + capabilities: tools.capabilities, + describe: (names) => tools.describe(names), + invoke: (request, options) => { + runner.assertActive(); + return tools.invoke(request, { + ...(options && ("aborted" in options ? { signal: options } : options)), + invoker: kernelToolInvokerStorage.getStore() ?? { + sessionId: runner.sessionManager.getSessionId(), + }, + }); + }, + } satisfies ExtensionKernelTools; }, get evalHandleHost() { runner.assertActive(); diff --git a/packages/coding-agent/src/core/extensions/wrapper.ts b/packages/coding-agent/src/core/extensions/wrapper.ts index a330ea7bc3..88e8b09bff 100644 --- a/packages/coding-agent/src/core/extensions/wrapper.ts +++ b/packages/coding-agent/src/core/extensions/wrapper.ts @@ -8,6 +8,7 @@ import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; import type { ToolDispatcher } from "../tool-dispatch/tool-dispatcher.ts"; import { wrapToolDefinition } from "../tools/tool-definition-wrapper.ts"; +import { kernelToolInvokerStorage } from "./kernel-tools-context.ts"; import type { ExtensionRunner } from "./runner.ts"; import type { ExtensionContext, RegisteredTool } from "./types.ts"; @@ -45,7 +46,9 @@ export function wrapRegisteredTool( ctx: context, sourcePath: registeredTool.sourceInfo.path, }) - : await registeredTool.definition.execute(toolCallId, params, signal, onUpdate, context); + : await kernelToolInvokerStorage.run({ sessionId: context.sessionManager.getSessionId() }, () => + registeredTool.definition.execute(toolCallId, params, signal, onUpdate, context), + ); } finally { invocation?.dispose(); } diff --git a/packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts b/packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts index 5def908c3f..1f86f41576 100644 --- a/packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts +++ b/packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts @@ -14,6 +14,7 @@ import { dirname, isAbsolute, resolve } from "node:path"; import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core"; import type { ToolRuntimeKind } from "../../cli/tool-runtime.ts"; import type { HostCallHandler } from "../../modes/rpc/tool-child/host-calls-server.ts"; +import { kernelToolInvokerStorage } from "../extensions/kernel-tools-context.ts"; import type { ExtensionToolContext, ToolChildJob, ToolDefinition, ToolPlacement } from "../extensions/types.ts"; import { type BashOperations, createLocalBashOperations } from "../tools/bash.ts"; import { childBashOperations } from "./child-bash-operations.ts"; @@ -175,7 +176,9 @@ abstract class BaseToolDispatcher implements ToolDispatcher { protected runOnServer(call: ToolDispatchCall): Promise> { this.emitDispatched({ toolCallId: call.toolCallId, placement: "server", pid: process.pid, generation: 0 }); - return call.definition.execute(call.toolCallId, call.params, call.signal, call.onUpdate, call.ctx); + return kernelToolInvokerStorage.run({ sessionId: call.ctx.sessionManager.getSessionId() }, () => + call.definition.execute(call.toolCallId, call.params, call.signal, call.onUpdate, call.ctx), + ); } protected emitDispatched(info: ToolDispatchedInfo): void { diff --git a/packages/senpi-codemode/src/kernels/js/context-manager.ts b/packages/senpi-codemode/src/kernels/js/context-manager.ts index 85055cfbb6..4aa060af39 100644 --- a/packages/senpi-codemode/src/kernels/js/context-manager.ts +++ b/packages/senpi-codemode/src/kernels/js/context-manager.ts @@ -58,6 +58,7 @@ export class JavaScriptKernel { readonly #kernelTools = new KernelToolHostPump( (message) => this.#slot.postMessage(message), () => this.#lifecycle === "open" && this.#slot.present, + (request, options) => this.#permissions.invocation(request, options), ); readonly #toolCalls = new ToolCallQueue(); readonly #children: WorkerChildren; @@ -131,12 +132,7 @@ export class JavaScriptKernel { options?: AbortSignal | KernelToolsInvokeOptions, ): Promise { await this.#permissions.admit(); - const release = this.#permissions.invocation(request, options); - try { - return await this.#kernelTools.invoke(request, options); - } finally { - release(); - } + return await this.#kernelTools.invoke(request, options); } async run(input: JavaScriptRunInput): Promise { diff --git a/packages/senpi-codemode/src/kernels/js/kernel-contract.ts b/packages/senpi-codemode/src/kernels/js/kernel-contract.ts index 8534baf329..15e67a97ee 100644 --- a/packages/senpi-codemode/src/kernels/js/kernel-contract.ts +++ b/packages/senpi-codemode/src/kernels/js/kernel-contract.ts @@ -2,6 +2,7 @@ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts"; import type { KernelToHostMessage } from "../../bridge/protocol.ts"; import type { EvalKernelRunInput } from "../../tool/types.ts"; import type { SessionEnvironment } from "../session-env.ts"; +import type { InvocationPermissions } from "../shared/invocation-authority.ts"; import type { JavaScriptInterruptBounds } from "./interrupt-bounds.ts"; import type { CellDecision, CellOperation, CellRule } from "./permission-slots.mjs"; @@ -43,8 +44,7 @@ export function resolveKernelToolNameSource(names?: KernelToolNameSource): strin export interface JavaScriptKernelOptions { readonly sessionId: string; readonly cwd: string; - readonly permissions?: { - readonly state: () => CellPermissionState; + readonly permissions?: InvocationPermissions & { readonly decide: CellDecider; }; /** Integration marks direct tool admission DEAD too; the kernel host fences cells itself. */ diff --git a/packages/senpi-codemode/src/kernels/js/permission-core.md b/packages/senpi-codemode/src/kernels/js/permission-core.md index 6498c462f5..3763199816 100644 --- a/packages/senpi-codemode/src/kernels/js/permission-core.md +++ b/packages/senpi-codemode/src/kernels/js/permission-core.md @@ -6,7 +6,7 @@ contract for a host `CellDecider`; it does not connect the permission extension. ## Host integration `JavaScriptKernelOptions.permissions` supplies a current epoch snapshot and -`decide(token, call)`. Tokens identify the owning session and execution cwd. +`decide(token, call)`. Tokens identify the invoking session and execution cwd. The decision is either `pass` or `deny` with a rule's permission, pattern and layer. The core does not interpret rule syntax or implement allow/ask precedence. @@ -17,9 +17,25 @@ abandoned retirement permanently fences cell admission for the session and emits `onRevocationFailed({ sessionId, kernelId })`. The integration PR must connect that event to direct-tool admission. Direct -tools are not fenced by this core alone. Cross-session kernel-tool refusal -belongs to PR 2b; `resolveInvocationToken(request, options)` is its host seam. -For now each invocation gets a distinct token minted by the kernel's owner. +tools are not fenced by this core alone. + +The coding-agent dispatcher stamps `options.invoker.sessionId` from its own +SessionManager. Neither cell data nor tool arguments can override that identity. +The shared host pump forwards these options to `resolveInvocationToken` before +posting an invocation frame. Absent or owner identity mints an owner token; +another session is refused before the body runs if either session enforces, +with the deciding rule attached. Otherwise its token identifies the invoker. +Python applies the same cross-session check before posting to the interpreter. + +Live kernel owners publish their current permission snapshots to the host-only +invocation registry. The permission integration supplies `permissions.resolveSession` +for sessions without a kernel, returning that session's current state and cwd. +Without integration or a registered kernel, a session has no enforcing state in +this core and retains the existing inactive behavior. No invoker identity or +session resolver is accepted by the worker protocol. An active JS invocation +also binds its destination kernel to the invoker's revocation lifecycle. A +settled inactive invocation keeps that binding until the worker retires, so +the invoker's revocation still reaches the continuations it left behind. ## Runtime invariants diff --git a/packages/senpi-codemode/src/kernels/js/permission-host.ts b/packages/senpi-codemode/src/kernels/js/permission-host.ts index 2ccd674b16..f01a2fe473 100644 --- a/packages/senpi-codemode/src/kernels/js/permission-host.ts +++ b/packages/senpi-codemode/src/kernels/js/permission-host.ts @@ -1,5 +1,10 @@ import { MessageChannel, type MessagePort, type Transferable, Worker, type WorkerOptions } from "node:worker_threads"; import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts"; +import { + invocationSession, + type InvocationSession, + registerInvocationSession, +} from "../shared/invocation-authority.ts"; import type { WorkerRetirement } from "./interrupt-bounds.ts"; import type { CellAuthorityToken, JavaScriptKernelOptions } from "./kernel-contract.ts"; import type { KernelToolsInvokeOptions, KernelToolsInvokeRequest } from "./kernel-tools-types.ts"; @@ -33,9 +38,11 @@ export class KernelPermissionHost { readonly #tokens = new Map(); readonly #runs = new Map(); readonly #invocations = new Map(); + readonly #invokerBindings = new Map(); readonly #pools = new Set(); readonly #ports = new Set(); readonly #children = new Map>(); + readonly #releaseSession: () => void; #kernel: CellAuthorityToken; #revoked = false; #retiring?: Promise; @@ -45,6 +52,7 @@ export class KernelPermissionHost { this.#retire = retire; this.#kernel = this.#mint(); this.#session = registerPermissionKernel(options.sessionId, this); + this.#releaseSession = registerInvocationSession(options.sessionId, () => this.#snapshot()); } get blocked(): boolean { @@ -61,29 +69,53 @@ export class KernelPermissionHost { if (this.#session.dead) throw new Error("Permission authority unavailable; restart the session"); } - #mint(signal?: AbortSignal): CellAuthorityToken { - const state = this.#options.permissions?.state() ?? { epoch: 0, active: false, rules: [] }; + #snapshot(): InvocationSession { + return { + cwd: this.#options.cwd, + state: this.#options.permissions?.state() ?? { epoch: 0, active: false, rules: [] }, + }; + } + + #mint( + signal?: AbortSignal, + session = { sessionId: this.#options.sessionId, ...this.#snapshot() }, + ): CellAuthorityToken { + const { state } = session; if (!Number.isInteger(state.epoch) || state.epoch < 0 || state.epoch >= 2 ** 29) throw new Error("Invalid permission epoch"); const authority = new SharedArrayBuffer(4); Atomics.store(new Int32Array(authority), 0, (state.epoch << 2) | 2 | Number(state.active)); return { id: crypto.randomUUID(), - sessionId: this.#options.sessionId, - cwd: this.#options.cwd, + sessionId: session.sessionId, + cwd: session.cwd, authority, rules: state.rules, signal, }; } - /** PR 2b adds invoker authority here; today the owning session alone mints the token. */ + /** Resolve host-only caller identity before admitting any invocation frame to the worker. */ resolveInvocationToken( _request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions, ): CellAuthorityToken { const signal = options && ("aborted" in options ? options : options.signal); - const token = this.#mint(signal); + const session = invocationSession( + this.#options.sessionId, + this.#snapshot(), + options, + this.#options.permissions?.resolveSession, + ); + const token = this.#mint(signal, session); + if (session.sessionId !== this.#options.sessionId) { + const binding = registerPermissionKernel(session.sessionId, this); + if (binding.dead || binding.pending) { + if (!this.#invokerBindings.has(session.sessionId)) unregisterPermissionKernel(session.sessionId, this); + throw new Error("Permission authority unavailable"); + } + this.#invokerBindings.set(session.sessionId, (this.#invokerBindings.get(session.sessionId) ?? 0) + 1); + } this.#tokens.set(token.id, token); return token; } @@ -130,9 +162,25 @@ export class KernelPermissionHost { } #release(token: CellAuthorityToken): void { - // Settled no-deny work keeps its async resources; the kernel word still revokes them. - if (Atomics.load(new Int32Array(token.authority), 0) & 1) this.#kill(token); - this.#tokens.delete(token.id); + if (!this.#tokens.delete(token.id)) return; + // Settled no-deny work keeps its async resources; the kernel word still revokes them. A live inactive + // token also keeps its invoker binding, so that session's revocation reaches the work until the worker retires. + const word = Atomics.load(new Int32Array(token.authority), 0); + if (word & 2 && !(word & 1)) return; + this.#kill(token); + const count = this.#invokerBindings.get(token.sessionId); + if (count === undefined) return; + if (count > 1) this.#invokerBindings.set(token.sessionId, count - 1); + else { + this.#invokerBindings.delete(token.sessionId); + unregisterPermissionKernel(token.sessionId, this); + } + } + + /** Worker retirement ends every continuation, so no invoker revocation needs to reach this kernel. */ + #unbindInvokers(): void { + for (const sessionId of this.#invokerBindings.keys()) unregisterPermissionKernel(sessionId, this); + this.#invokerBindings.clear(); } #kill(token: CellAuthorityToken): void { @@ -286,12 +334,15 @@ export class KernelPermissionHost { for (const [worker] of children) void worker.terminate(); await Promise.all(children.map(([, exited]) => exited)); for (const port of this.#ports) port.close(); - this.#tokens.clear(); + for (const token of this.#tokens.values()) this.#release(token); + this.#unbindInvokers(); this.#runs.clear(); } dispose(): void { this.revoke(); + this.#releaseSession(); + this.#unbindInvokers(); unregisterPermissionKernel(this.#options.sessionId, this); } } diff --git a/packages/senpi-codemode/src/kernels/py/kernel-contract.ts b/packages/senpi-codemode/src/kernels/py/kernel-contract.ts index 4b2cc544c7..29f38251e2 100644 --- a/packages/senpi-codemode/src/kernels/py/kernel-contract.ts +++ b/packages/senpi-codemode/src/kernels/py/kernel-contract.ts @@ -3,6 +3,7 @@ import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/p import type { EvalKernelRunInput } from "../../tool/types.ts"; import type { SessionEnvironment } from "../session-env.ts"; import type { KernelLifecycle } from "../shared/kernel-death.ts"; +import type { InvocationPermissions } from "../shared/invocation-authority.ts"; import type { PeerKernelToolsDescribe } from "./kernel-tools-host.ts"; import type { KernelSpawnProcess, KillProcessGroup } from "./process.ts"; import type { PythonStartupStage } from "./startup.ts"; @@ -12,6 +13,7 @@ export interface PythonKernelStartOptions extends KernelLifecycle { readonly interpreterPath: string; readonly sessionId: string; readonly cwd: string; + readonly permissions?: InvocationPermissions; readonly connection: BridgeConnectionConfig; readonly env?: NodeJS.ProcessEnv; /** Per-session PI_* values merged into the interpreter environment at spawn. */ diff --git a/packages/senpi-codemode/src/kernels/py/kernel.ts b/packages/senpi-codemode/src/kernels/py/kernel.ts index 2a60f91799..307bf5f7e7 100644 --- a/packages/senpi-codemode/src/kernels/py/kernel.ts +++ b/packages/senpi-codemode/src/kernels/py/kernel.ts @@ -6,6 +6,7 @@ import type { KernelToolsInvokeRequest, } from "../js/kernel-tools-types.ts"; import { inputAtStart } from "../shared/cell-source-at-start.ts"; +import { invocationSession, registerInvocationSession } from "../shared/invocation-authority.ts"; import { KernelMemoryHost } from "../shared/kernel-memory-host.ts"; import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts"; import { pythonDeathReason } from "./death-reason.ts"; @@ -47,9 +48,14 @@ export class PythonKernel { #closed = false; readonly #tools: PythonKernelTools; #dead = false; + readonly #releaseSession: () => void; private constructor(options: PythonKernelStartOptions) { this.#options = options; + this.#releaseSession = registerInvocationSession(options.sessionId, () => ({ + cwd: options.cwd, + state: options.permissions?.state() ?? { epoch: 0, active: false, rules: [] }, + })); this.#memory = options.memory === undefined ? null : new KernelMemoryHost("py", options.memory); this.#tools = new PythonKernelTools({ post: (message) => this.#transport?.post(message), @@ -60,8 +66,13 @@ export class PythonKernel { static async start(options: PythonKernelStartOptions): Promise { const kernel = new PythonKernel(options); - await kernel.#spawn(kernel.#generation); - return kernel; + try { + await kernel.#spawn(kernel.#generation); + return kernel; + } catch (error) { + kernel.#releaseSession(); + throw error; + } } listKernelToolNames(): readonly string[] { @@ -88,11 +99,20 @@ export class PythonKernel { return this.#tools.describe(names); } - invokeKernelTool( + async invokeKernelTool( request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions, ): Promise { - return this.#tools.invoke(request, options); + invocationSession( + this.#options.sessionId, + { + cwd: this.#options.cwd, + state: this.#options.permissions?.state() ?? { epoch: 0, active: false, rules: [] }, + }, + options, + this.#options.permissions?.resolveSession, + ); + return await this.#tools.invoke(request, options); } run(input: PythonKernelRunOptions): Promise { @@ -185,6 +205,7 @@ export class PythonKernel { async close(): Promise { if (this.#closePromise) return await this.#closePromise; this.#closed = true; + this.#releaseSession(); this.#generation += 1; this.#tools.retire("tools_unavailable", "Python kernel is closed"); this.#settleAllPending("Python kernel closed"); diff --git a/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts b/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts new file mode 100644 index 0000000000..03bba37f43 --- /dev/null +++ b/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts @@ -0,0 +1,56 @@ +import type { CellPermissionState } from "../js/kernel-contract.ts"; +import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts"; + +export interface InvocationSession { + readonly cwd: string; + readonly state: CellPermissionState; +} + +export interface InvocationPermissions { + readonly state: () => CellPermissionState; + /** Integration resolves live permission state even for an invoker that has never opened a kernel. */ + readonly resolveSession?: (sessionId: string) => InvocationSession; +} + +const sessions = new Map InvocationSession>>(); + +export function registerInvocationSession(sessionId: string, read: () => InvocationSession): () => void { + let entries = sessions.get(sessionId); + if (!entries) { + entries = new Set(); + sessions.set(sessionId, entries); + } + entries.add(read); + return () => { + entries.delete(read); + if (entries.size === 0) sessions.delete(sessionId); + }; +} + +export function invocationSession( + ownerId: string, + owner: InvocationSession, + options?: AbortSignal | KernelToolsInvokeOptions, + resolveSession?: InvocationPermissions["resolveSession"], +): InvocationSession & { readonly sessionId: string } { + const invokerId = options && !("aborted" in options) ? options.invoker?.sessionId : undefined; + if (invokerId === undefined || invokerId === ownerId) return { sessionId: ownerId, ...owner }; + const registered = [...(sessions.get(invokerId) ?? [])].map((read) => read()); + const invoker = resolveSession?.(invokerId) ?? registered.find((session) => session.state.active) ?? registered[0]; + for (const state of [owner.state, invoker?.state]) { + if (!state?.active) continue; + const rule = state.rules[0]; + throw Object.assign( + new Error( + rule + ? `Cross-session kernel tool refused by ${rule.permission} ${rule.pattern} (${rule.layer})` + : "Cross-session kernel tool refused: permission authority unavailable", + ), + { code: "cell_permission_denied", rule }, + ); + } + return { + sessionId: invokerId, + ...(invoker ?? { cwd: owner.cwd, state: { epoch: 0, active: false, rules: [] } }), + }; +} diff --git a/packages/senpi-codemode/src/kernels/shared/kernel-tools-pump.ts b/packages/senpi-codemode/src/kernels/shared/kernel-tools-pump.ts index af27bbf6ea..eeecdead3a 100644 --- a/packages/senpi-codemode/src/kernels/shared/kernel-tools-pump.ts +++ b/packages/senpi-codemode/src/kernels/shared/kernel-tools-pump.ts @@ -24,10 +24,19 @@ export class KernelToolHostPump { readonly #waiters = new Map(); readonly #post: (message: HostToKernelMessage) => void; readonly #isOpen: () => boolean; + readonly #invocation?: ( + request: KernelToolsInvokeRequest, + options?: AbortSignal | KernelToolsInvokeOptions, + ) => () => void; - constructor(post: (message: HostToKernelMessage) => void, isOpen: () => boolean) { + constructor( + post: (message: HostToKernelMessage) => void, + isOpen: () => boolean, + invocation?: (request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions) => () => void, + ) { this.#post = post; this.#isOpen = isOpen; + this.#invocation = invocation; } consume(message: KernelToHostMessage): boolean { @@ -70,6 +79,18 @@ export class KernelToolHostPump { * the message it always did (#1731). */ async invoke(request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions): Promise { + const release = this.#invocation?.(request, options); + try { + return await this.#invoke(request, options); + } finally { + release?.(); + } + } + + async #invoke( + request: KernelToolsInvokeRequest, + options?: AbortSignal | KernelToolsInvokeOptions, + ): Promise { const { signal, scope } = normalizeInvokeOptions(options); this.events.dispatchEvent(new Event("nestedInvoke")); const reply = await this.#request( diff --git a/packages/senpi-codemode/test/kernel-invocation-token.test.ts b/packages/senpi-codemode/test/kernel-invocation-token.test.ts new file mode 100644 index 0000000000..f170874ffc --- /dev/null +++ b/packages/senpi-codemode/test/kernel-invocation-token.test.ts @@ -0,0 +1,111 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { JavaScriptKernel, revokeSessionKernels } from "../src/kernels/js/context-manager.ts"; +import type { CellPermissionState } from "../src/kernels/js/kernel-contract.ts"; +import { KernelPermissionHost } from "../src/kernels/js/permission-host.ts"; + +// #4309: the host-minted token, not caller arguments, chooses the permission session and cwd. +describe("kernel invocation tokens", () => { + it("keeps a settled cross-session continuation alive yet revocable by its invoker", async () => { + const root = await mkdtemp(join(tmpdir(), "invoker-continuation-")); + const invokerId = crypto.randomUUID(); + let invoker: CellPermissionState = { epoch: 1, active: false, rules: [] }; + const kernel = new JavaScriptKernel({ + sessionId: crypto.randomUUID(), + cwd: root, + parallelPoolWidth: 2, + permissions: { + state: () => ({ epoch: 1, active: false, rules: [] }), + resolveSession: (sessionId) => + sessionId === invokerId + ? { cwd: root, state: invoker } + : { cwd: root, state: { epoch: 0, active: false, rules: [] } }, + decide: () => ({ action: "deny", rule: { permission: "write", pattern: "*", layer: "user" } }), + }, + }); + const run = (code: string) => kernel.run({ cellId: crypto.randomUUID(), code, timeoutMs: 8_000 }); + const schedule = async (name: string) => { + const entry = (await kernel.describeKernelTools(["schedule"])).results[0]; + if (!entry?.ok) throw new Error("missing schedule descriptor"); + await kernel.invokeKernelTool( + { ...entry.descriptor, args: { name }, call_id: crypto.randomUUID() }, + { invoker: { sessionId: invokerId } }, + ); + }; + try { + expect( + await run(`var gates = {}; var results = {}; + tool(function schedule(name) { + gates[name] = Promise.withResolvers(); + results[name] = gates[name].promise.then(() => { + try { require("node:fs").writeFileSync(${JSON.stringify(root)} + "/" + name, "ok"); return "wrote"; } + catch (error) { return error.code; } + }); + return name; + });`), + ).toMatchObject({ ok: true }); + // No session enforces: the settled invocation's continuation keeps working, as before the core. + await schedule("parity"); + expect(await run(`gates.parity.resolve(); return await results.parity;`)).toMatchObject({ + ok: true, + valueRepr: '"wrote"', + }); + expect(existsSync(join(root, "parity"))).toBe(true); + // The invoker then starts enforcing: its revocation still reaches the continuation it left behind. + await schedule("revoked"); + invoker = { epoch: 2, active: true, rules: [{ permission: "write", pattern: "*", layer: "user" }] }; + await revokeSessionKernels(invokerId); + await run(`gates.revoked?.resolve(); return await results.revoked;`); + expect(existsSync(join(root, "revoked"))).toBe(false); + } finally { + await kernel.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + it("mints owner tokens for absent/same invokers and invoking-session tokens otherwise", async () => { + const host = new KernelPermissionHost( + { + sessionId: "token-owner", + cwd: "/owner", + parallelPoolWidth: 2, + permissions: { + state: () => ({ epoch: 1, active: false, rules: [] }), + resolveSession: () => ({ + cwd: "/invoker", + state: { epoch: 2, active: false, rules: [] }, + }), + decide: () => ({ action: "pass" }), + }, + }, + async () => "terminated", + ); + const signal = new AbortController().signal; + const request = { + name: "probe", + kernel_generation: 1, + definition_revision: 1, + args: { invoker: { sessionId: "forged" } }, + call_id: "probe", + }; + try { + for (const invoker of [undefined, { sessionId: "token-owner" }, { sessionId: "token-invoker" }]) { + const token = host.resolveInvocationToken(request, { signal, invoker }); + expect(token).toMatchObject({ + sessionId: invoker?.sessionId ?? "token-owner", + cwd: invoker?.sessionId === "token-invoker" ? "/invoker" : "/owner", + signal, + }); + expect(Atomics.load(new Int32Array(token.authority), 0)).toBe( + invoker?.sessionId === "token-invoker" ? 10 : 6, + ); + } + } finally { + await host.terminateChildren(); + host.dispose(); + } + }); +}); diff --git a/packages/senpi-codemode/test/kernel-tool-invoker-authority.test.ts b/packages/senpi-codemode/test/kernel-tool-invoker-authority.test.ts new file mode 100644 index 0000000000..1725617d81 --- /dev/null +++ b/packages/senpi-codemode/test/kernel-tool-invoker-authority.test.ts @@ -0,0 +1,111 @@ +import { pathToFileURL } from "node:url"; +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import { AuthStorage } from "../../coding-agent/src/core/auth-storage.ts"; +import { createExtensionRuntime } from "../../coding-agent/src/core/extensions/loader.ts"; +import { kernelToolsStorage } from "../../coding-agent/src/core/extensions/kernel-tools-context.ts"; +import { ExtensionRunner } from "../../coding-agent/src/core/extensions/runner.ts"; +import { ModelRegistry } from "../../coding-agent/src/core/model-registry.ts"; +import { SessionManager } from "../../coding-agent/src/core/session-manager.ts"; +import { InProcessToolDispatcher } from "../../coding-agent/src/core/tool-dispatch/tool-dispatcher.ts"; +import { JavaScriptKernel } from "../src/kernels/js/context-manager.ts"; + +// #4309: the real dispatcher and worker boundary own invoker identity, not cell arguments. +describe.each(["worker", "inline"] as const)("kernel-tool invoker authority (%s)", (mode) => { + it.each([ + { ownerActive: true, invokerActive: false, same: true, denied: false }, + { ownerActive: true, invokerActive: false, same: false, denied: true }, + { ownerActive: false, invokerActive: true, same: false, denied: true }, + { ownerActive: false, invokerActive: false, same: false, denied: false }, + ])("decides before the body runs: %j", async ({ ownerActive, invokerActive, same, denied }) => { + const owner = SessionManager.inMemory(); + const caller = same ? owner : SessionManager.inMemory(); + const rule = { permission: "bash", pattern: "uname", layer: "user" }; + const options = (sessionId: string, active: boolean) => ({ + sessionId, + cwd: process.cwd(), + parallelPoolWidth: 2, + ...(mode === "inline" ? { workerEntryUrl: pathToFileURL("/missing-invoker-worker.js") } : {}), + permissions: { + state: () => ({ active, epoch: 1, rules: active ? [rule] : [] }), + decide: () => ({ action: "pass" as const }), + }, + }); + const kernel = new JavaScriptKernel(options(owner.getSessionId(), ownerActive)); + const invokingKernel = same ? undefined : new JavaScriptKernel(options(caller.getSessionId(), invokerActive)); + const runner = new ExtensionRunner( + [], + createExtensionRuntime(), + process.cwd(), + caller, + ModelRegistry.inMemory(AuthStorage.inMemory()), + ); + const ownerRunner = new ExtensionRunner( + [], + createExtensionRuntime(), + process.cwd(), + owner, + ModelRegistry.inMemory(AuthStorage.inMemory()), + ); + try { + expect( + await kernel.run({ + cellId: "define", + code: `let calls = 0; tool(function probe(invoker) { calls++; return invoker; });`, + }), + ).toMatchObject({ ok: true }); + const entry = (await kernel.describeKernelTools(["probe"])).results[0]; + if (!entry?.ok) throw new Error("missing probe descriptor"); + const capability = { + capabilities: { invokeScope: true }, + describe: kernel.describeKernelTools.bind(kernel), + invoke: kernel.invokeKernelTool.bind(kernel), + }; + const invoke = () => + kernelToolsStorage.run(capability, async () => { + // A task grants a capability captured in the parent, not one created by the child. + const tools = ownerRunner.createContext().kernelTools; + if (!tools) throw new Error("missing dispatcher capability"); + const dispatched = await new InProcessToolDispatcher().run({ + toolCallId: "child-granted-tool", + params: {}, + ctx: runner.createToolContext("child-granted-tool", undefined), + definition: { + name: "granted", + label: "Granted", + description: "Invoke the parent's granted kernel closure", + parameters: Type.Object({}), + execute: async () => ({ + content: [], + details: await tools.invoke( + { + ...entry.descriptor, + args: { invoker: { sessionId: owner.getSessionId() } }, + call_id: crypto.randomUUID(), + }, + { invoker: { sessionId: owner.getSessionId() } }, + ), + }), + }, + }); + return dispatched.details; + }); + if (denied) { + await expect(invoke()).rejects.toMatchObject({ + code: "cell_permission_denied", + rule, + message: expect.stringContaining(rule.pattern), + }); + } else { + expect(await invoke()).toEqual({ sessionId: owner.getSessionId() }); + } + expect(await kernel.run({ cellId: "count", code: "return calls;" })).toMatchObject({ + ok: true, + valueRepr: denied ? "0" : "1", + }); + } finally { + await kernel.close(); + await invokingKernel?.close(); + } + }); +}); diff --git a/packages/senpi-codemode/test/py-kernel-invoker-authority.test.ts b/packages/senpi-codemode/test/py-kernel-invoker-authority.test.ts new file mode 100644 index 0000000000..2913c813dc --- /dev/null +++ b/packages/senpi-codemode/test/py-kernel-invoker-authority.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { createInterpreterDetector } from "../src/interpreters/detect.ts"; +import { PythonKernel } from "../src/kernels/py/kernel.ts"; + +const python = await createInterpreterDetector().detect("py"); + +// #4309: a Python closure must not share retained resources across enforcing sessions either. +describe.skipIf(!python.ok)("Python invoker authority", () => { + it.each([ + { ownerActive: true, invokerActive: false, same: true, denied: false }, + { ownerActive: true, invokerActive: false, same: false, denied: true }, + { ownerActive: false, invokerActive: true, same: false, denied: true }, + { ownerActive: false, invokerActive: false, same: false, denied: false }, + ])("refuses before the closure executes: %j", async ({ ownerActive, invokerActive, same, denied }) => { + if (!python.ok) throw new Error("Python unavailable"); + const ownerId = crypto.randomUUID(); + const invokerId = same ? ownerId : crypto.randomUUID(); + const rule = { permission: "read", pattern: "secret.txt", layer: "user" }; + const kernel = await PythonKernel.start({ + interpreterPath: python.path, + sessionId: ownerId, + cwd: process.cwd(), + connection: { port: 1, token: "unused" }, + permissions: { + state: () => ({ epoch: 1, active: ownerActive, rules: ownerActive ? [rule] : [] }), + // The invoking session has no kernel: integration supplies its live permission snapshot. + resolveSession: () => ({ + cwd: process.cwd(), + state: { epoch: 2, active: invokerActive, rules: invokerActive ? [rule] : [] }, + }), + }, + }); + const run = (code: string) => kernel.run({ cellId: crypto.randomUUID(), code, timeoutMs: 8_000 }); + try { + expect( + await run("calls = 0\n@tool\ndef probe(invoker):\n global calls\n calls += 1\n return invoker"), + ).toMatchObject({ ok: true }); + const entry = (await kernel.describeKernelTools(["probe"])).results[0]; + if (!entry?.ok) throw new Error("missing Python probe"); + const invoked = kernel.invokeKernelTool( + { + ...entry.descriptor, + args: { invoker: { sessionId: ownerId } }, + call_id: crypto.randomUUID(), + }, + { invoker: { sessionId: invokerId } }, + ); + if (denied) await expect(invoked).rejects.toMatchObject({ code: "cell_permission_denied", rule }); + else expect(await invoked).toEqual({ sessionId: ownerId }); + expect(await run("calls")).toMatchObject({ ok: true, valueRepr: denied ? "0" : "1" }); + } finally { + await kernel.close(); + } + }); +}); From f6975e3ac61023b221dec107b0f3484b14fd59bc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 27 Oct 2026 00:20:39 +0900 Subject: [PATCH 2/6] docs: link invoker authority changes to PR 4390 --- changes/unreleased/{4309.md => 4390.md} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename changes/unreleased/{4309.md => 4390.md} (92%) diff --git a/changes/unreleased/4309.md b/changes/unreleased/4390.md similarity index 92% rename from changes/unreleased/4309.md rename to changes/unreleased/4390.md index 9dc1d7f2d6..b9589c346b 100644 --- a/changes/unreleased/4309.md +++ b/changes/unreleased/4390.md @@ -1,5 +1,5 @@ -## 2026-10-26 - Host-bound kernel-tool invoker identity (part of senpi#4309) +## 2026-10-26 - Host-bound kernel-tool invoker identity (senpi#4390, part of #4309) ### What changed @@ -26,7 +26,7 @@ - `packages/coding-agent/src/core/extensions/wrapper.ts`: registered tool execution. -## 2026-10-26 - Bind kernel invoker authority at tool dispatch (part of senpi#4309) +## 2026-10-26 - Bind kernel invoker authority at tool dispatch (senpi#4390, part of #4309) ### What changed @@ -45,7 +45,7 @@ - `packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts`: BaseToolDispatcher.runOnServer. -## 2026-10-26 - Cross-session kernel-tool permission authority (part of senpi#4309) +## 2026-10-26 - Cross-session kernel-tool permission authority (senpi#4390, part of #4309) ### What changed @@ -70,9 +70,9 @@ ### Added -- Bind kernel-tool invocation identity to the dispatcher's own session rather than tool arguments (part of #4309). +- Bind kernel-tool invocation identity to the dispatcher's own session rather than tool arguments ([#4390](https://github.com/code-yeongyu/senpi/pull/4390), part of #4309). ### Added -- Refuse cross-session JavaScript and Python kernel tools before their bodies run when either session enforces a deny rule, and mint invoker-scoped JS tokens when neither enforces (part of #4309). +- Refuse cross-session JavaScript and Python kernel tools before their bodies run when either session enforces a deny rule, and mint invoker-scoped JS tokens when neither enforces ([#4390](https://github.com/code-yeongyu/senpi/pull/4390), part of #4309). - Known limitation: reads performed by the runtime's native module resolver (bare packages, tsconfig aliases, package.json #imports, symlinked packages) are not covered by this work; tracked in #4380. From b280d4cb5e92e18e05313a1688e2b6121e6545af Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 27 Oct 2026 12:12:44 +0900 Subject: [PATCH 3/6] fix(tool-dispatch): preserve context-free invocations Keep no-session contexts on the owner-authority path and clear ambient invokers. Cover all supported context shapes across both dispatch runtimes, and approve only PR 4390 invocation gate additions. --- changes/unreleased/4390.md | 3 +- .../tool-dispatch/invoker-context.test.ts | 79 +++++++++++++++++++ .../src/core/tool-dispatch/tool-dispatcher.ts | 10 ++- .../senpi-codemode/test/gate/allowlist.json | 21 +++++ .../senpi-codemode/test/gate/baseline.json | 2 + 5 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts diff --git a/changes/unreleased/4390.md b/changes/unreleased/4390.md index b9589c346b..c2f77437d5 100644 --- a/changes/unreleased/4390.md +++ b/changes/unreleased/4390.md @@ -30,7 +30,7 @@ ### What changed -- `packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts`: bind the calling session's identity in host async context around server tool execution. +- `packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts`: bind the calling session's identity in host async context around server tool execution; context-free wrappers and child/observer contexts without a session manager retain the owner path without inheriting an ambient invoker. ### Why @@ -53,6 +53,7 @@ - `packages/senpi-codemode/src/kernels/shared/invocation-authority.ts` and `kernel-tools-pump.ts`: resolve host-only session snapshots, forward invocation options into the host hook, and release authority on settlement. - `packages/senpi-codemode/src/kernels/py/kernel.ts` and `kernel-contract.ts`: apply the same refusal before Python tool bodies execute. - `packages/senpi-codemode/src/kernels/js/permission-core.md`: document the integration resolver for invoking sessions without kernels. +- `packages/senpi-codemode/test/gate/allowlist.json` and `baseline.json`: approve only this PR's two invocation-authority import memberships and thirteen invocation contracts, preserving every inherited baseline cell and approval. ### Why diff --git a/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts b/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts new file mode 100644 index 0000000000..89ab43e318 --- /dev/null +++ b/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts @@ -0,0 +1,79 @@ +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import type { ToolRuntimeKind } from "../../cli/tool-runtime.ts"; +import { kernelToolInvokerStorage } from "../extensions/kernel-tools-context.ts"; +import type { ExtensionToolContext, ToolDefinition } from "../extensions/types.ts"; +import { SessionManager } from "../session-manager.ts"; +import { wrapToolDefinition } from "../tools/tool-definition-wrapper.ts"; +import { createToolDispatcher } from "./tool-dispatcher.ts"; + +const manager = SessionManager.inMemory(); +const contexts: Array<{ name: string; context: ExtensionToolContext | undefined; sessionId?: string }> = [ + { name: "no context", context: undefined }, + { name: "observer context", context: {} as ExtensionToolContext }, + { + name: "child context without readable properties", + context: new Proxy({} as ExtensionToolContext, { + get: (_target, key) => { + throw new Error(`unexpected context access: ${String(key)}`); + }, + }), + }, + { name: "absent session manager", context: { sessionManager: undefined } as unknown as ExtensionToolContext }, + { + name: "own session manager", + context: { sessionManager: manager } as unknown as ExtensionToolContext, + sessionId: manager.getSessionId(), + }, + { + name: "inherited session manager", + context: Object.create({ sessionManager: manager }) as ExtensionToolContext, + sessionId: manager.getSessionId(), + }, + { + name: "lazy session manager", + context: { + get sessionManager() { + return manager; + }, + } as unknown as ExtensionToolContext, + sessionId: manager.getSessionId(), + }, +]; + +// #4390 / #4309: dispatch may legitimately have no context. Such calls must use +// the kernel owner's authority, never a previous dispatch's ambient identity. +describe.each(["in-process", "child"])("invoker context (%s)", (runtime) => { + describe.each([undefined, { sessionId: "outer-session" }])("with ambient invoker %j", (ambient) => { + it.each(contexts)("binds only the available host identity: $name", async ({ context, sessionId }) => { + const dispatcher = createToolDispatcher({ runtime }); + const notices: string[] = []; + dispatcher.onDispatched((info) => notices.push(info.placement)); + const definition: ToolDefinition = { + name: "probe", + label: "probe", + description: "probe", + parameters: Type.Object({}), + execute: async (_id, _params, _signal, _update, receivedContext) => { + expect(receivedContext).toBe(context); + const before = kernelToolInvokerStorage.getStore(); + await Promise.resolve(); + expect(kernelToolInvokerStorage.getStore()).toEqual(before); + return { content: [], details: before }; + }, + }; + const run = async () => { + // Exercise the actual context-free wrapping path, not a fabricated + // ToolDispatchCall with a falsely required context. + const tool = wrapToolDefinition(definition, context ? () => context : undefined, dispatcher); + const result = await tool.execute("probe", {}); + expect(result.details).toEqual(sessionId === undefined ? undefined : { sessionId }); + expect(kernelToolInvokerStorage.getStore()).toEqual(ambient); + expect(notices).toEqual(["server"]); + }; + if (ambient) await kernelToolInvokerStorage.run(ambient, run); + else await run(); + expect(kernelToolInvokerStorage.getStore()).toBeUndefined(); + }); + }); +}); diff --git a/packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts b/packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts index 1f86f41576..1f866ead04 100644 --- a/packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts +++ b/packages/coding-agent/src/core/tool-dispatch/tool-dispatcher.ts @@ -176,9 +176,13 @@ abstract class BaseToolDispatcher implements ToolDispatcher { protected runOnServer(call: ToolDispatchCall): Promise> { this.emitDispatched({ toolCallId: call.toolCallId, placement: "server", pid: process.pid, generation: 0 }); - return kernelToolInvokerStorage.run({ sessionId: call.ctx.sessionManager.getSessionId() }, () => - call.definition.execute(call.toolCallId, call.params, call.signal, call.onUpdate, call.ctx), - ); + const execute = () => call.definition.execute(call.toolCallId, call.params, call.signal, call.onUpdate, call.ctx); + // Context-free wrappers and child/observer contexts have no host identity. + // Do not read an absent capability or inherit another dispatch's invoker. + const manager = call.ctx && "sessionManager" in call.ctx ? call.ctx.sessionManager : undefined; + return manager + ? kernelToolInvokerStorage.run({ sessionId: manager.getSessionId() }, execute) + : kernelToolInvokerStorage.exit(execute); } protected emitDispatched(info: ToolDispatchedInfo): void { diff --git a/packages/senpi-codemode/test/gate/allowlist.json b/packages/senpi-codemode/test/gate/allowlist.json index 0a3974ffc2..cd959bc205 100644 --- a/packages/senpi-codemode/test/gate/allowlist.json +++ b/packages/senpi-codemode/test/gate/allowlist.json @@ -1,6 +1,27 @@ { "version": 1, "nodes": { + "senpi#4390": { + "additions": [ + "imports/extension/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts", + "imports/firstKernel/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts", + "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens keeps a settled cross-session continuation alive yet revocable by its invoker", + "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens mints owner tokens for absent/same invokers and invoking-session tokens otherwise", + "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (worker) decides before the body runs: {\"ownerActive\":true,\"invokerActive\":false,\"same\":true,\"denied\":false}", + "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (worker) decides before the body runs: {\"ownerActive\":true,\"invokerActive\":false,\"same\":false,\"denied\":true}", + "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (worker) decides before the body runs: {\"ownerActive\":false,\"invokerActive\":true,\"same\":false,\"denied\":true}", + "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (worker) decides before the body runs: {\"ownerActive\":false,\"invokerActive\":false,\"same\":false,\"denied\":false}", + "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (inline) decides before the body runs: {\"ownerActive\":true,\"invokerActive\":false,\"same\":true,\"denied\":false}", + "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (inline) decides before the body runs: {\"ownerActive\":true,\"invokerActive\":false,\"same\":false,\"denied\":true}", + "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (inline) decides before the body runs: {\"ownerActive\":false,\"invokerActive\":true,\"same\":false,\"denied\":true}", + "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (inline) decides before the body runs: {\"ownerActive\":false,\"invokerActive\":false,\"same\":false,\"denied\":false}", + "legacyContracts/test/py-kernel-invoker-authority.test.ts/Python invoker authority refuses before the closure executes: {\"ownerActive\":true,\"invokerActive\":false,\"same\":true,\"denied\":false}", + "legacyContracts/test/py-kernel-invoker-authority.test.ts/Python invoker authority refuses before the closure executes: {\"ownerActive\":true,\"invokerActive\":false,\"same\":false,\"denied\":true}", + "legacyContracts/test/py-kernel-invoker-authority.test.ts/Python invoker authority refuses before the closure executes: {\"ownerActive\":false,\"invokerActive\":true,\"same\":false,\"denied\":true}", + "legacyContracts/test/py-kernel-invoker-authority.test.ts/Python invoker authority refuses before the closure executes: {\"ownerActive\":false,\"invokerActive\":false,\"same\":false,\"denied\":false}" + ], + "reason": "senpi#4390: approve only the shared invoker-authority module's extension/first-kernel memberships and this PR's fourteen token and JS/Python invocation contracts. They prove host-bound session/cwd/epoch attribution, same-session admission, inactive cross-session compatibility (a settled inactive continuation stays revocable by its invoker), and refusal before the body runs when either session enforces. No inherited permission-core addition or existing baseline value is changed." + }, "senpi#4387": { "additions": [ "imports/extension/packages/senpi-codemode/src/kernels/js/permission-host.ts", diff --git a/packages/senpi-codemode/test/gate/baseline.json b/packages/senpi-codemode/test/gate/baseline.json index 301f4f49f2..84c6ce0ad3 100644 --- a/packages/senpi-codemode/test/gate/baseline.json +++ b/packages/senpi-codemode/test/gate/baseline.json @@ -4974,6 +4974,7 @@ "packages/senpi-codemode/src/kernels/js/kernel-memory-bridge.ts", "packages/senpi-codemode/src/kernels/js/kernel-tools-errors.ts", "packages/senpi-codemode/src/kernels/shared/kernel-tools-pump.ts", + "packages/senpi-codemode/src/kernels/shared/invocation-authority.ts", "packages/senpi-codemode/src/kernels/js/kernel-tools-types.ts", "packages/senpi-codemode/src/kernels/js/local-module-loader.ts", "packages/senpi-codemode/src/kernels/js/process-tree-host.ts", @@ -5121,6 +5122,7 @@ "packages/senpi-codemode/src/kernels/js/kernel-tools-errors.js", "packages/senpi-codemode/src/kernels/js/kernel-tools-errors.ts", "packages/senpi-codemode/src/kernels/shared/kernel-tools-pump.ts", + "packages/senpi-codemode/src/kernels/shared/invocation-authority.ts", "packages/senpi-codemode/src/kernels/js/kernel-tools-naming.js", "packages/senpi-codemode/src/kernels/js/kernel-tools-parse.js", "packages/senpi-codemode/src/kernels/js/kernel-tools-pump.js", From 17c35d6dd383945e7936f20fa2cdd2a9fd23bc8e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 27 Oct 2026 12:35:16 +0900 Subject: [PATCH 4/6] fix: bind event invokers and clarify refusal context Fold both LOW findings from the PR 4390 review. Registered handlers use their emitting session even inside a parent task. Cross-session errors identify the authority barrier and enforcing session rather than claiming an operation-specific rule match. --- changes/unreleased/4390.md | 10 +- .../src/core/extensions/loader.ts | 14 ++- .../tool-dispatch/invoker-context.test.ts | 91 ++++++++++++++++++- .../src/kernels/js/permission-core.md | 7 +- .../kernels/shared/invocation-authority.ts | 9 +- .../senpi-codemode/test/gate/allowlist.json | 5 +- .../test/kernel-invocation-token.test.ts | 28 ++++++ 7 files changed, 153 insertions(+), 11 deletions(-) diff --git a/changes/unreleased/4390.md b/changes/unreleased/4390.md index c2f77437d5..c1af1abd18 100644 --- a/changes/unreleased/4390.md +++ b/changes/unreleased/4390.md @@ -6,24 +6,28 @@ - `packages/coding-agent/src/core/extensions/kernel-tools-context.ts`: add host-only invocation identity. - `packages/coding-agent/src/core/extensions/runner.ts`: stamp the invoking SessionManager's identity after every caller-supplied option. - `packages/coding-agent/src/core/extensions/wrapper.ts`: bind dispatcher-free tool calls to their host context. +- `packages/coding-agent/src/core/extensions/loader.ts`: bind registered event and hook handlers to their emitting host context without replacing their registered-handler metadata. ### Why - `packages/coding-agent/src/core/extensions/kernel-tools-context.ts`: cross-session kernel permissions need a host-authenticated caller. - `packages/coding-agent/src/core/extensions/runner.ts`: cell arguments and inherited capabilities must not select the permission session. - `packages/coding-agent/src/core/extensions/wrapper.ts`: a capability captured in a parent still runs with the child's host identity. +- `packages/coding-agent/src/core/extensions/loader.ts`: a child handler must not inherit a parent's task-tool invoker and bypass cross-session admission. ### Why an extension could not handle it - `packages/coding-agent/src/core/extensions/kernel-tools-context.ts`: this contract crosses the dispatcher and kernel host. - `packages/coding-agent/src/core/extensions/runner.ts`: only the dispatcher owns the calling session's SessionManager. - `packages/coding-agent/src/core/extensions/wrapper.ts`: the host wrapper owns the fallback execution boundary. +- `packages/coding-agent/src/core/extensions/loader.ts`: handler registration is the common host boundary for every event and hook dispatch path. ### Expected merge conflict zones - `packages/coding-agent/src/core/extensions/kernel-tools-context.ts`: invocation options. - `packages/coding-agent/src/core/extensions/runner.ts`: the lazy kernelTools context getter. - `packages/coding-agent/src/core/extensions/wrapper.ts`: registered tool execution. +- `packages/coding-agent/src/core/extensions/loader.ts`: the owned registered-handler wrapper. ## 2026-10-26 - Bind kernel invoker authority at tool dispatch (senpi#4390, part of #4309) @@ -49,11 +53,11 @@ ### What changed -- `packages/senpi-codemode/src/kernels/js/permission-host.ts`, `kernel-contract.ts`, and `context-manager.ts`: mint invoker tokens and reject cross-session invocations before dispatch when either session enforces. -- `packages/senpi-codemode/src/kernels/shared/invocation-authority.ts` and `kernel-tools-pump.ts`: resolve host-only session snapshots, forward invocation options into the host hook, and release authority on settlement. +- `packages/senpi-codemode/src/kernels/js/permission-host.ts`, `kernel-contract.ts`, and `context-manager.ts`: mint invoker tokens and reject cross-session invocations before dispatch when either session enforces. A settled inactive invocation keeps its invoker binding until the worker retires, so that session's revocation still reaches the continuations it left behind. +- `packages/senpi-codemode/src/kernels/shared/invocation-authority.ts` and `kernel-tools-pump.ts`: resolve host-only session snapshots, forward invocation options into the host hook, and release authority on settlement. Cross-session refusals identify the authority barrier and enforcing session, not an operation matched by the first deny rule. - `packages/senpi-codemode/src/kernels/py/kernel.ts` and `kernel-contract.ts`: apply the same refusal before Python tool bodies execute. - `packages/senpi-codemode/src/kernels/js/permission-core.md`: document the integration resolver for invoking sessions without kernels. -- `packages/senpi-codemode/test/gate/allowlist.json` and `baseline.json`: approve only this PR's two invocation-authority import memberships and thirteen invocation contracts, preserving every inherited baseline cell and approval. +- `packages/senpi-codemode/test/gate/allowlist.json` and `baseline.json`: approve only this PR's two invocation-authority import memberships and invocation contracts, preserving every inherited baseline cell and approval. ### Why diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 5a3d446375..78c6dd8c01 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -52,6 +52,7 @@ import { extensionModuleImporter, rememberExtensionFactory, } from "./extension-module-cache.ts"; +import { kernelToolInvokerStorage } from "./kernel-tools-context.ts"; import { createLifecycleApi } from "./lifecycle-api.ts"; import { StaleExtensionContextError } from "./stale-context-error.ts"; import type { @@ -63,6 +64,7 @@ import type { EntryRendererOptions, Extension, ExtensionAPI, + ExtensionContext, ExtensionFactory, ExtensionRuntime, ExtensionSessionProfile, @@ -74,6 +76,7 @@ import type { MemoryReporter, MessageRenderer, PendingProviderRegistration, + ProjectTrustContext, ProviderConfig, RegisteredChildModule, RegisteredCommand, @@ -430,7 +433,16 @@ function createExtensionAPI( options?: BeforeAgentStartHandlerOptions & ContextHandlerOptions, ): () => void { assertActive(); - const registeredHandler: HandlerFn = ownedBy(extension, (...args: unknown[]) => handler(...args)); + const registeredHandler: HandlerFn = ownedBy(extension, (...args: unknown[]) => { + // A child event can run inside its parent's task-tool async scope. + // Bind the host context here, preserving registered-handler metadata. + const context = args[1] as ExtensionContext | ProjectTrustContext | undefined; + const manager = context && "sessionManager" in context ? context.sessionManager : undefined; + const invoke = () => handler(...args); + return manager + ? kernelToolInvokerStorage.run({ sessionId: manager.getSessionId() }, invoke) + : kernelToolInvokerStorage.exit(invoke); + }); const list = extension.handlers.get(event) ?? []; list.push(registeredHandler); extension.handlers.set(event, list); diff --git a/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts b/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts index 89ab43e318..29ad423a18 100644 --- a/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts +++ b/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts @@ -1,8 +1,13 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import type { ToolRuntimeKind } from "../../cli/tool-runtime.ts"; -import { kernelToolInvokerStorage } from "../extensions/kernel-tools-context.ts"; -import type { ExtensionToolContext, ToolDefinition } from "../extensions/types.ts"; +import { AuthStorage } from "../auth-storage.ts"; +import { createEventBus } from "../event-bus.ts"; +import { kernelToolInvokerStorage, kernelToolsStorage } from "../extensions/kernel-tools-context.ts"; +import { createExtensionRuntime, loadExtensionFromFactory } from "../extensions/loader.ts"; +import { ExtensionRunner } from "../extensions/runner.ts"; +import type { ExtensionContext, ExtensionToolContext, ToolDefinition } from "../extensions/types.ts"; +import { ModelRegistry } from "../model-registry.ts"; import { SessionManager } from "../session-manager.ts"; import { wrapToolDefinition } from "../tools/tool-definition-wrapper.ts"; import { createToolDispatcher } from "./tool-dispatcher.ts"; @@ -77,3 +82,85 @@ describe.each(["in-process", "child"])("invoker context (%s)", }); }); }); + +// #4390 review: a child event inherits the parent's task-tool async scope. +// All registered event paths must stamp the child, including captured capabilities. +describe("registered event invoker context", () => { + const emissions: Array<{ name: string; emit: (runner: ExtensionRunner) => Promise }> = [ + { name: "session event", emit: (runner) => runner.emit({ type: "session_start", reason: "startup" }) }, + { name: "queued lifecycle", emit: (runner) => runner.emit({ type: "agent_start" }) }, + { + name: "tool call hook", + emit: (runner) => + runner.emitToolCall({ type: "tool_call", toolName: "probe", toolCallId: "probe", input: {} }), + }, + { + name: "tool result hook", + emit: (runner) => + runner.emitToolResult({ + type: "tool_result", + toolName: "probe", + toolCallId: "probe", + input: {}, + content: [], + details: undefined, + isError: false, + }), + }, + { name: "context transform", emit: (runner) => runner.emitContext([]) }, + { + name: "preview-safe prompt hook", + emit: (runner) => + runner.emitBeforeAgentStart("probe", undefined, "", { cwd: process.cwd() }, { preview: true }), + }, + ]; + + it.each(emissions)("uses the handler's session for $name", async ({ emit }) => { + const owner = SessionManager.inMemory(); + const child = SessionManager.inMemory(); + const registry = ModelRegistry.inMemory(AuthStorage.inMemory()); + const ownerRunner = new ExtensionRunner([], createExtensionRuntime(), process.cwd(), owner, registry); + const runtime = createExtensionRuntime(); + const observed: string[] = []; + const request = { name: "probe", kernel_generation: 1, definition_revision: 1, args: {}, call_id: "probe" }; + await kernelToolsStorage.run( + { + capabilities: { invokeScope: true }, + describe: async () => undefined, + invoke: async (_request, options) => { + if (!options || "aborted" in options || !options.invoker) throw new Error("missing invoker"); + observed.push(options.invoker.sessionId); + }, + }, + async () => { + const captured = ownerRunner.createContext().kernelTools; + if (!captured) throw new Error("missing owner capability"); + const extension = await loadExtensionFromFactory( + (pi) => { + const invoke = async (_event: unknown, context: ExtensionContext) => { + await Promise.resolve(); + await context.kernelTools?.invoke(request); + await captured.invoke(request); + }; + pi.on("session_start", invoke); + pi.on("agent_start", invoke); + pi.on("tool_call", invoke); + pi.on("tool_result", invoke); + pi.on("context", invoke, { mutatesMessages: false }); + pi.on("before_agent_start", invoke, { previewSafe: true }); + }, + process.cwd(), + createEventBus(), + runtime, + "invoker-context.ts", + ); + const runner = new ExtensionRunner([extension], runtime, process.cwd(), child, registry); + await kernelToolInvokerStorage.run({ sessionId: owner.getSessionId() }, async () => { + await emit(runner); + expect(observed).toEqual([child.getSessionId(), child.getSessionId()]); + expect(kernelToolInvokerStorage.getStore()).toEqual({ sessionId: owner.getSessionId() }); + }); + }, + ); + }); +}); diff --git a/packages/senpi-codemode/src/kernels/js/permission-core.md b/packages/senpi-codemode/src/kernels/js/permission-core.md index 3763199816..5b320a97dd 100644 --- a/packages/senpi-codemode/src/kernels/js/permission-core.md +++ b/packages/senpi-codemode/src/kernels/js/permission-core.md @@ -21,10 +21,15 @@ tools are not fenced by this core alone. The coding-agent dispatcher stamps `options.invoker.sessionId` from its own SessionManager. Neither cell data nor tool arguments can override that identity. +Registered extension event handlers also bind their own host context, rather +than inheriting a parent task tool's identity when a child emits an event. The shared host pump forwards these options to `resolveInvocationToken` before posting an invocation frame. Absent or owner identity mints an owner token; another session is refused before the body runs if either session enforces, -with the deciding rule attached. Otherwise its token identifies the invoker. +with `reason: "cross_session_authority"` and the enforcing session identified. +The attached `rule` names a deny rule activating that session's enforcement; +it does not claim that this rule matched an operation inside the unexecuted +tool body. Otherwise the token identifies the invoker. Python applies the same cross-session check before posting to the interpreter. Live kernel owners publish their current permission snapshots to the host-only diff --git a/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts b/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts index 03bba37f43..27cf3a228b 100644 --- a/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts +++ b/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts @@ -37,16 +37,19 @@ export function invocationSession( if (invokerId === undefined || invokerId === ownerId) return { sessionId: ownerId, ...owner }; const registered = [...(sessions.get(invokerId) ?? [])].map((read) => read()); const invoker = resolveSession?.(invokerId) ?? registered.find((session) => session.state.active) ?? registered[0]; - for (const state of [owner.state, invoker?.state]) { + for (const [enforcingSessionId, state] of [ + [ownerId, owner.state], + [invokerId, invoker?.state], + ] as const) { if (!state?.active) continue; const rule = state.rules[0]; throw Object.assign( new Error( rule - ? `Cross-session kernel tool refused by ${rule.permission} ${rule.pattern} (${rule.layer})` + ? `Cross-session kernel tool refused while session ${enforcingSessionId} enforces deny rules, including ${rule.permission} ${rule.pattern} (${rule.layer})` : "Cross-session kernel tool refused: permission authority unavailable", ), - { code: "cell_permission_denied", rule }, + { code: "cell_permission_denied", reason: "cross_session_authority", enforcingSessionId, rule }, ); } return { diff --git a/packages/senpi-codemode/test/gate/allowlist.json b/packages/senpi-codemode/test/gate/allowlist.json index cd959bc205..20a5bd1113 100644 --- a/packages/senpi-codemode/test/gate/allowlist.json +++ b/packages/senpi-codemode/test/gate/allowlist.json @@ -7,6 +7,9 @@ "imports/firstKernel/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts", "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens keeps a settled cross-session continuation alive yet revocable by its invoker", "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens mints owner tokens for absent/same invokers and invoking-session tokens otherwise", + "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens identifies the cross-session barrier when owner enforces", + "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens identifies the cross-session barrier when invoker enforces", + "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens identifies the cross-session barrier when both enforces", "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (worker) decides before the body runs: {\"ownerActive\":true,\"invokerActive\":false,\"same\":true,\"denied\":false}", "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (worker) decides before the body runs: {\"ownerActive\":true,\"invokerActive\":false,\"same\":false,\"denied\":true}", "legacyContracts/test/kernel-tool-invoker-authority.test.ts/kernel-tool invoker authority (worker) decides before the body runs: {\"ownerActive\":false,\"invokerActive\":true,\"same\":false,\"denied\":true}", @@ -20,7 +23,7 @@ "legacyContracts/test/py-kernel-invoker-authority.test.ts/Python invoker authority refuses before the closure executes: {\"ownerActive\":false,\"invokerActive\":true,\"same\":false,\"denied\":true}", "legacyContracts/test/py-kernel-invoker-authority.test.ts/Python invoker authority refuses before the closure executes: {\"ownerActive\":false,\"invokerActive\":false,\"same\":false,\"denied\":false}" ], - "reason": "senpi#4390: approve only the shared invoker-authority module's extension/first-kernel memberships and this PR's fourteen token and JS/Python invocation contracts. They prove host-bound session/cwd/epoch attribution, same-session admission, inactive cross-session compatibility (a settled inactive continuation stays revocable by its invoker), and refusal before the body runs when either session enforces. No inherited permission-core addition or existing baseline value is changed." + "reason": "senpi#4390: approve only the shared invoker-authority module's extension/first-kernel memberships and this PR's seventeen token and JS/Python invocation contracts. They prove host-bound session/cwd/epoch attribution, same-session admission, inactive cross-session compatibility (a settled inactive continuation stays revocable by its invoker), refusal before the body runs when either session enforces, and the cross-session reason with the enforcing session identified. No inherited permission-core addition or existing baseline value is changed." }, "senpi#4387": { "additions": [ diff --git a/packages/senpi-codemode/test/kernel-invocation-token.test.ts b/packages/senpi-codemode/test/kernel-invocation-token.test.ts index f170874ffc..8141b4377b 100644 --- a/packages/senpi-codemode/test/kernel-invocation-token.test.ts +++ b/packages/senpi-codemode/test/kernel-invocation-token.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { JavaScriptKernel, revokeSessionKernels } from "../src/kernels/js/context-manager.ts"; import type { CellPermissionState } from "../src/kernels/js/kernel-contract.ts"; import { KernelPermissionHost } from "../src/kernels/js/permission-host.ts"; +import { invocationSession } from "../src/kernels/shared/invocation-authority.ts"; // #4309: the host-minted token, not caller arguments, chooses the permission session and cwd. describe("kernel invocation tokens", () => { @@ -66,6 +67,33 @@ describe("kernel invocation tokens", () => { } }); + it.each([ + { name: "owner", ownerActive: true, invokerActive: false, enforcingSessionId: "owner" }, + { name: "invoker", ownerActive: false, invokerActive: true, enforcingSessionId: "invoker" }, + { name: "both", ownerActive: true, invokerActive: true, enforcingSessionId: "owner" }, + ])( + "identifies the cross-session barrier when $name enforces", + ({ ownerActive, invokerActive, enforcingSessionId }) => { + const rule = { permission: "bash", pattern: "unrelated-command", layer: "user" }; + const snapshot = (active: boolean) => ({ + cwd: "/work", + state: { epoch: 1, active, rules: active ? [rule] : [] }, + }); + expect(() => + invocationSession("owner", snapshot(ownerActive), { invoker: { sessionId: "invoker" } }, () => + snapshot(invokerActive), + ), + ).toThrowError( + expect.objectContaining({ + code: "cell_permission_denied", + reason: "cross_session_authority", + enforcingSessionId, + rule, + }), + ); + }, + ); + it("mints owner tokens for absent/same invokers and invoking-session tokens otherwise", async () => { const host = new KernelPermissionHost( { From fb6ae1dcc2d4fffc691e7de834df91455bcfae24 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 27 Oct 2026 19:10:12 +0900 Subject: [PATCH 5/6] test(senpi-codemode): pin interrupt-time active kill and spread-free port discovery Fold the two LOW findings from the #4387 delta review at 49d718a2a9: - js-kernel-permission-interrupt.test.ts holds a host decision, interrupts, and asserts the active token word is dead before any result frame and the held write never lands. Replacing the interrupt-time #release with a plain registry delete fails it. - receivedPorts pushes collection entries one at a time, so a frame holding a port inside an array or Set past the engine's argument limit is delivered with the port working instead of degrading to messageerror. The transfer scenario adds a 3,000,000-element array row. --- changes/unreleased/4390.md | 1 + .../kernels/js/permission-worker-frames.ts | 8 +-- .../test/fixtures/permission-worker-driver.ts | 8 +-- .../senpi-codemode/test/gate/allowlist.json | 4 +- .../js-kernel-permission-interrupt.test.ts | 52 +++++++++++++++++++ 5 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 packages/senpi-codemode/test/js-kernel-permission-interrupt.test.ts diff --git a/changes/unreleased/4390.md b/changes/unreleased/4390.md index c1af1abd18..1ee42779f3 100644 --- a/changes/unreleased/4390.md +++ b/changes/unreleased/4390.md @@ -54,6 +54,7 @@ ### What changed - `packages/senpi-codemode/src/kernels/js/permission-host.ts`, `kernel-contract.ts`, and `context-manager.ts`: mint invoker tokens and reject cross-session invocations before dispatch when either session enforces. A settled inactive invocation keeps its invoker binding until the worker retires, so that session's revocation still reaches the continuations it left behind. +- `packages/senpi-codemode/src/kernels/js/permission-worker-frames.ts`: discover transferred MessagePorts without spreading collections, so a frame with an array or Set past the engine's argument limit is still delivered (#4387 review follow-up). - `packages/senpi-codemode/src/kernels/shared/invocation-authority.ts` and `kernel-tools-pump.ts`: resolve host-only session snapshots, forward invocation options into the host hook, and release authority on settlement. Cross-session refusals identify the authority barrier and enforcing session, not an operation matched by the first deny rule. - `packages/senpi-codemode/src/kernels/py/kernel.ts` and `kernel-contract.ts`: apply the same refusal before Python tool bodies execute. - `packages/senpi-codemode/src/kernels/js/permission-core.md`: document the integration resolver for invoking sessions without kernels. diff --git a/packages/senpi-codemode/src/kernels/js/permission-worker-frames.ts b/packages/senpi-codemode/src/kernels/js/permission-worker-frames.ts index 4bb01552dd..5ce652c0db 100644 --- a/packages/senpi-codemode/src/kernels/js/permission-worker-frames.ts +++ b/packages/senpi-codemode/src/kernels/js/permission-worker-frames.ts @@ -33,9 +33,11 @@ function receivedPorts(value: unknown): MessagePort[] { if (next === null || typeof next !== "object" || seen.has(next)) continue; seen.add(next); if (next instanceof MessagePort) ports.push(next); - else if (next instanceof Map) for (const entry of next) pending.push(...entry); - else if (next instanceof Set || Array.isArray(next)) pending.push(...next); - else if (Object.getPrototypeOf(next) === Object.prototype) pending.push(...Object.values(next)); + // Push one value at a time: spreading a collection past the engine's argument limit throws RangeError. + else if (next instanceof Map) for (const [key, entry] of next) pending.push(key, entry); + else if (next instanceof Set || Array.isArray(next)) for (const entry of next) pending.push(entry); + else if (Object.getPrototypeOf(next) === Object.prototype) + for (const entry of Object.values(next)) pending.push(entry); } return ports; } diff --git a/packages/senpi-codemode/test/fixtures/permission-worker-driver.ts b/packages/senpi-codemode/test/fixtures/permission-worker-driver.ts index 50f1fc7da1..b74fff87b9 100644 --- a/packages/senpi-codemode/test/fixtures/permission-worker-driver.ts +++ b/packages/senpi-codemode/test/fixtures/permission-worker-driver.ts @@ -109,6 +109,8 @@ try { const bare=open(); parentPort.postMessage(bare,[bare]); const nested=open(); parentPort.postMessage({p:nested},[nested]); const keyed=open(); postMessage({p:keyed},[keyed]); + // Longer than the engine's spread-argument limit, so port discovery cannot spread it. + const deep=open(); const huge=new Array(3000000).fill(0); huge[1500000]=deep; parentPort.postMessage(huge,[deep]); parentPort.postMessage("unforwardable"); postMessage("done"); }; @@ -121,7 +123,7 @@ try { var frames=[]; var errors=[]; child.onmessageerror=e=>errors.push(e.data?.message); var received=new Promise(resolve=>child.onmessage=e=>{if(e.data==="done") resolve(); else frames.push(e.data);}); child.postMessage(null); await received; - var ports=[frames[0], frames[1]?.p, frames[2]?.p]; + var ports=[frames[0], frames[1]?.p, frames[2]?.p, frames[3]?.[1500000]]; var replies=await Promise.all(ports.map(port=>new Promise((resolve,reject)=>{ if(typeof port?.postMessage!=="function") return reject(new Error("missing port: "+String(port))); port.on("message",reply=>{port.close(); resolve(reply);}); port.postMessage("ping"); @@ -129,8 +131,8 @@ try { child.terminate(); return {frames:frames.length, replies, errors};`), JSON.stringify({ - frames: 3, - replies: ["pong:ping", "pong:ping", "pong:ping"], + frames: 4, + replies: ["pong:ping", "pong:ping", "pong:ping", "pong:ping"], errors: ["Worker message could not be forwarded (DataCloneError): forced clone failure"], }), ); diff --git a/packages/senpi-codemode/test/gate/allowlist.json b/packages/senpi-codemode/test/gate/allowlist.json index 20a5bd1113..1aa1cabbcf 100644 --- a/packages/senpi-codemode/test/gate/allowlist.json +++ b/packages/senpi-codemode/test/gate/allowlist.json @@ -5,6 +5,8 @@ "additions": [ "imports/extension/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts", "imports/firstKernel/packages/senpi-codemode/src/kernels/shared/invocation-authority.ts", + "legacyContracts/test/js-kernel-permission-interrupt.test.ts/permission interrupt window (worker) kills active authority before the result and refuses work held in that window", + "legacyContracts/test/js-kernel-permission-interrupt.test.ts/permission interrupt window (inline) kills active authority before the result and refuses work held in that window", "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens keeps a settled cross-session continuation alive yet revocable by its invoker", "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens mints owner tokens for absent/same invokers and invoking-session tokens otherwise", "legacyContracts/test/kernel-invocation-token.test.ts/kernel invocation tokens identifies the cross-session barrier when owner enforces", @@ -23,7 +25,7 @@ "legacyContracts/test/py-kernel-invoker-authority.test.ts/Python invoker authority refuses before the closure executes: {\"ownerActive\":false,\"invokerActive\":true,\"same\":false,\"denied\":true}", "legacyContracts/test/py-kernel-invoker-authority.test.ts/Python invoker authority refuses before the closure executes: {\"ownerActive\":false,\"invokerActive\":false,\"same\":false,\"denied\":false}" ], - "reason": "senpi#4390: approve only the shared invoker-authority module's extension/first-kernel memberships and this PR's seventeen token and JS/Python invocation contracts. They prove host-bound session/cwd/epoch attribution, same-session admission, inactive cross-session compatibility (a settled inactive continuation stays revocable by its invoker), refusal before the body runs when either session enforces, and the cross-session reason with the enforcing session identified. No inherited permission-core addition or existing baseline value is changed." + "reason": "senpi#4390: approve only the shared invoker-authority module's extension/first-kernel memberships and this PR's seventeen token and JS/Python invocation contracts plus the two interrupt-window contracts folded from the #4387 delta review. They prove host-bound session/cwd/epoch attribution, same-session admission, inactive cross-session compatibility (a settled inactive continuation stays revocable by its invoker), refusal before the body runs when either session enforces, and the cross-session reason with the enforcing session identified. No inherited permission-core addition or existing baseline value is changed." }, "senpi#4387": { "additions": [ diff --git a/packages/senpi-codemode/test/js-kernel-permission-interrupt.test.ts b/packages/senpi-codemode/test/js-kernel-permission-interrupt.test.ts new file mode 100644 index 0000000000..3ab0d6fe8b --- /dev/null +++ b/packages/senpi-codemode/test/js-kernel-permission-interrupt.test.ts @@ -0,0 +1,52 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { JavaScriptKernel } from "../src/kernels/js/context-manager.ts"; +import type { CellAuthorityToken } from "../src/kernels/js/kernel-contract.ts"; + +// #4309: an interrupt kills the active run token at once, not only when the result frame settles it. +describe.each(["worker", "inline"] as const)("permission interrupt window (%s)", (mode) => { + it("kills active authority before the result and refuses work held in that window", async () => { + const root = await mkdtemp(join(tmpdir(), "eval-interrupt-window-")); + const marker = join(root, "after-interrupt"); + const decided = Promise.withResolvers(); + const held = Promise.withResolvers<{ action: "pass" }>(); + const kernel = new JavaScriptKernel({ + sessionId: crypto.randomUUID(), + cwd: root, + parallelPoolWidth: 2, + ...(mode === "inline" ? { workerEntryUrl: pathToFileURL(join(root, "missing.js")) } : {}), + permissions: { + state: () => ({ epoch: 1, active: true, rules: [{ permission: "bash", pattern: "uname", layer: "user" }] }), + decide: (token) => { + decided.resolve(token); + return held.promise; + }, + }, + }); + try { + const running = kernel.run({ + cellId: "held", + timeoutMs: 8_000, + code: `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "late");`, + }); + const token = await decided.promise; + const word = () => Atomics.load(new Int32Array(token.authority), 0); + expect(word() & 3).toBe(3); + const interrupting = kernel.interrupt("permission interrupt window"); + // The interrupt frame is posted synchronously; no result frame can have been handled before this line. + expect(word() & 2).toBe(0); + held.resolve({ action: "pass" }); + await interrupting; + expect(await running).toMatchObject({ ok: false }); + expect(existsSync(marker)).toBe(false); + } finally { + held.resolve({ action: "pass" }); + await kernel.close(); + await rm(root, { recursive: true, force: true }); + } + }); +}); From dff56fad71a9031988689ef465255e5dffc39673 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 28 Oct 2026 00:01:38 +0900 Subject: [PATCH 6/6] fix(coding-agent): run stale-runner handlers without an invoker binding Session replacement (8c749e5ff4 on main) emits session_extensions_removed on the old runner after it is invalidated. The #4390 handler wrapper read context.sessionManager first, whose guarded getter throws StaleExtensionContextError, so the removed extension's handler never ran. The wrapper now resolves the handler's session without letting a stale context abort the dispatch: a stale context binds no invoker and runs the handler under kernelToolInvokerStorage.exit, so it never borrows the emitting scope's identity, and the context's own guarded getters (kernelTools included) still refuse it. Other errors still propagate. --- changes/unreleased/4390.md | 2 +- .../src/core/extensions/loader.ts | 22 ++++++++-- .../tool-dispatch/invoker-context.test.ts | 40 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/changes/unreleased/4390.md b/changes/unreleased/4390.md index 1ee42779f3..75614b2bd0 100644 --- a/changes/unreleased/4390.md +++ b/changes/unreleased/4390.md @@ -6,7 +6,7 @@ - `packages/coding-agent/src/core/extensions/kernel-tools-context.ts`: add host-only invocation identity. - `packages/coding-agent/src/core/extensions/runner.ts`: stamp the invoking SessionManager's identity after every caller-supplied option. - `packages/coding-agent/src/core/extensions/wrapper.ts`: bind dispatcher-free tool calls to their host context. -- `packages/coding-agent/src/core/extensions/loader.ts`: bind registered event and hook handlers to their emitting host context without replacing their registered-handler metadata. +- `packages/coding-agent/src/core/extensions/loader.ts`: bind registered event and hook handlers to their emitting host context without replacing their registered-handler metadata. A stale context (session replacement reports `session_extensions_removed` on the invalidated runner) binds no invoker, so its handler still runs without borrowing the emitting scope's identity. ### Why diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 78c6dd8c01..f0144edc9a 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -394,6 +394,21 @@ export function drainPendingProviderRegistrations(runtime: ExtensionRuntime): Pe * Registration methods write to the extension object. * Action methods delegate to the shared runtime. */ +/** + * The session a handler's host context belongs to. A stale context (session replacement still reports + * `session_extensions_removed` on the invalidated runner) has none: its handler runs with no ambient + * invoker, and its own guarded getters, kernelTools included, still refuse it. + */ +function handlerSessionId(context: ExtensionContext | ProjectTrustContext | undefined): string | undefined { + if (!context || !("sessionManager" in context)) return undefined; + try { + return context.sessionManager?.getSessionId(); + } catch (error) { + if (error instanceof StaleExtensionContextError) return undefined; + throw error; + } +} + function createExtensionAPI( extension: Extension, runtime: ExtensionRuntime, @@ -436,11 +451,10 @@ function createExtensionAPI( const registeredHandler: HandlerFn = ownedBy(extension, (...args: unknown[]) => { // A child event can run inside its parent's task-tool async scope. // Bind the host context here, preserving registered-handler metadata. - const context = args[1] as ExtensionContext | ProjectTrustContext | undefined; - const manager = context && "sessionManager" in context ? context.sessionManager : undefined; + const sessionId = handlerSessionId(args[1] as ExtensionContext | ProjectTrustContext | undefined); const invoke = () => handler(...args); - return manager - ? kernelToolInvokerStorage.run({ sessionId: manager.getSessionId() }, invoke) + return sessionId + ? kernelToolInvokerStorage.run({ sessionId }, invoke) : kernelToolInvokerStorage.exit(invoke); }); const list = extension.handlers.get(event) ?? []; diff --git a/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts b/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts index 29ad423a18..e8b743e799 100644 --- a/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts +++ b/packages/coding-agent/src/core/tool-dispatch/invoker-context.test.ts @@ -115,6 +115,46 @@ describe("registered event invoker context", () => { }, ]; + // Session replacement reports removed extensions on the old, already invalidated runner. + it("runs a stale runner's handler without borrowing the emitting scope's invoker", async () => { + const owner = SessionManager.inMemory(); + const runtime = createExtensionRuntime(); + const observed: Array<{ invoker: unknown; kernelTools: string }> = []; + const extension = await loadExtensionFromFactory( + (pi) => { + pi.on("session_extensions_removed", (_event, context) => { + let kernelTools = "readable"; + try { + void context.kernelTools; + } catch (error) { + kernelTools = error instanceof Error ? error.name : String(error); + } + observed.push({ invoker: kernelToolInvokerStorage.getStore(), kernelTools }); + }); + }, + process.cwd(), + createEventBus(), + runtime, + "removed-invoker.ts", + ); + const runner = new ExtensionRunner( + [extension], + runtime, + process.cwd(), + SessionManager.inMemory(), + ModelRegistry.inMemory(AuthStorage.inMemory()), + ); + runner.invalidate(); + await kernelToolInvokerStorage.run({ sessionId: owner.getSessionId() }, () => + runner.emit({ + type: "session_extensions_removed", + reason: "new", + removed: [{ path: "removed-invoker.ts", resolvedPath: "removed-invoker.ts" }], + }), + ); + expect(observed).toEqual([{ invoker: undefined, kernelTools: "StaleExtensionContextError" }]); + }); + it.each(emissions)("uses the handler's session for $name", async ({ emit }) => { const owner = SessionManager.inMemory(); const child = SessionManager.inMemory();