Skip to content

feat(eval): call-scoped host-tool policy for kernel-tool invoke - #1765

Merged
code-yeongyu merged 3 commits into
mainfrom
feat/1731-invoke-scope
Sep 16, 2026
Merged

code-yeongyu merged 3 commits into
mainfrom
feat/1731-invoke-scope

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Summary

kernelTools.invoke(request, options?) now accepts a per-call execution scope for the nested host calls the invoked closure makes:

await ctx.kernelTools.invoke(request, { scope: { tools: { deny: ["write"] } } });

While that invocation is active, a tool.<name>() outside the scope is refused inside the worker — before anything reaches the host bridge — with the typed error kernel_tool_host_denied carrying { tool, call_id, reason: "allow" | "deny" } on the invoking call's channel. The closure sees a rejected promise; the parent's own top-level cells and the parent run queue keep the parent's full tool surface.

Semantics:

  • deny wins over allow; an allow list refuses every host tool it does not name (an empty allow refuses everything); a malformed list fails closed instead of widening the call.
  • The scope lives in the call-scoped bridge store only: it dies when the call settles (including interrupt/reset) and is never persisted or carried into the next invocation.
  • The second argument still accepts a bare AbortSignal, and a call without a scope posts exactly the frame it always did.

Consumers detect the capability with kernelTools.capabilities.invokeScope === true (KERNEL_TOOLS_CAPABILITIES), so a host that still runs an older runtime can keep refusing narrowed grants instead of sending an option that would be silently ignored.

Tests

Written failing-first (test(eval): RED contract … commit), then made green:

  • test/kernel-tools-scope.test.js — policy seam: allow/deny/absent, deny-wins, exact name match, malformed list fails closed, refusal payload.
  • test/kernel-tools-invoke-scope.test.ts — live worker at the kernel seam: denied nested call never reaches the bridge, allowed one does, closure observes the rejection, unscoped invoke unchanged, scope dropped at settle, parent cell + queue + later cells unaffected.
  • test/kernel-tools-invoke-scope-e2e.test.ts — real worker through the eval tool, in the style of kernel-tools-host-dispatch.test.ts (fix(eval): enter the kernel-tool capability at each host tool dispatch #1755): a host tool resolves ctx.kernelTools, invokes the parent's closures with scope.tools.deny: ["write"]; the nested tool.read succeeds and tool.write fails closed with kernel_tool_host_denied; the host never sees a write call.
  • scripts/qa-kernel-tool-scope.ts — real-surface QA driver for the kernel/bridge change:
DENIED={"settled":"rejected","code":"kernel_tool_host_denied","details":{"tool":"write","call_id":"qa-denied","reason":"deny"}}
ALLOWED="nested-body"
PARENT={"ok":true,"valueRepr":"\"parent-done\""}
HOST_TOOL_CALLS=["hold","read"]
QA PASS

Verified on a Darwin arm64 runner with node 24+/bun 1.4.2: full packages/senpi-codemode suite 132 files / 929 tests passed (1 file, 14 tests skipped), root tsc --noEmit clean, biome check --error-on-warnings, check:ts-imports and check:entry-graphs clean.

Notes

  • Nothing outside packages/senpi-codemode changed. A typed consumer reading ExtensionContext.kernelTools (declared in packages/coding-agent) still needs a cast to pass { scope } or read capabilities; widening that declaration is a separate, non-blocking follow-up.

Fixes #1731


Summary by cubic

Adds call-scoped host-tool policy to kernelTools.invoke, so a consumer granting a parent kernel tool to a child can narrow which host tools the invoked closure reaches instead of choosing between refusing the grant and letting it run with the parent's full permissions (#1731). A nested tool.<name>() outside the scope is refused inside the worker with kernel_tool_host_denied carrying { tool, call_id, reason } on the invoking call's channel — the closure sees a rejected promise, the refusal never reaches the host bridge, and the parent's own cells and queue keep the full tool surface. Nothing outside packages/senpi-codemode changed; a typed consumer reading ExtensionContext.kernelTools still needs a cast to pass { scope } or read capabilities.

Semantics

  • deny wins over allow; an allow list refuses every host tool it does not name, and an empty or malformed list fails closed.
  • The scope lives only for that call: dropped when it settles (including interrupt/reset), never persisted, never carried into the next invocation; a scoped nested call pending at parent interrupt settles exactly once with kernel_tool_stale.
  • The second argument still accepts a bare AbortSignal, and a call without a scope posts exactly the frame it always did.
  • Consumers gate on kernelTools.capabilities.invokeScope === true, so a host on an older runtime can refuse narrowed grants instead of sending an option that would be silently ignored.

Written for commit 3ea9e79. Summary will update on new commits.

Review in cubic

Covers the policy seam (allow/deny/absent, deny-wins, exact name match,
typed refusal payload), the live-worker kernel seam (denied nested host
call never reaches the bridge, allowed one does, scope drops at settle,
parent cell and queue unaffected) and the real-worker e2e where a host
tool invokes the parent closures under scope.tools.deny.

Refs #1731
kernelTools.invoke now takes an optional per-call execution scope
({ scope: { tools: { allow, deny } } }) that the worker applies to the
nested host calls the invoked closure makes. A call outside the scope is
refused inside the worker with kernel_tool_host_denied carrying
{ tool, call_id, reason }, so the closure sees a rejected promise, the
refusal never reaches the host bridge, and the parent's own cells and
queue keep the parent's full tool surface. deny wins over allow, an allow
list refuses every host tool it does not name, a malformed list fails
closed, and the scope lives only for that call. A bare AbortSignal still
works and an unscoped call posts the message it always did; consumers
gate on kernelTools.capabilities.invokeScope.

Fixes #1731
@code-yeongyu
code-yeongyu merged commit da9397e into main Sep 16, 2026
23 checks passed
@code-yeongyu
code-yeongyu deleted the feat/1731-invoke-scope branch September 16, 2026 14:38
DescodeNet pushed a commit to DescodeNet/senpi that referenced this pull request Sep 17, 2026
…ope surface

PR code-yeongyu#1765 shipped kernelTools.invoke(request, { signal?, scope? }) and capabilities.invokeScope, but ExtensionContext.kernelTools still declared invoke(request, signal?: AbortSignal) with no capabilities. Alias ExtensionKernelTools to senpi-codemode KernelToolsCapability so the public typed surface cannot drift.

Refs code-yeongyu#1731
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(eval): call-scoped host-tool policy for kernel-tool invoke (nested host calls run parent-permissioned)

1 participant