From d3b811963faeae13aa0eb0bd66059bfa50f9b771 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 18 Jul 2026 22:22:05 -0700 Subject: [PATCH 1/4] test: [de-fork S2] execution-route matrix + sentinel tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage S2 of the de-fork spike: the authoritative map of every tool-execution DISPATCHER (the point where a resolved tool's `.execute()` actually runs), so S3's HardPolicy knows every chokepoint it must cover. Analysis + executable sentinel tests only — zero product-code change. - `docs/internal/defork-route-matrix.md` — ingress surfaces vs execution dispatchers (D1-D8), each classified active / latent / out-of-scope with file:line evidence. Key findings: `SessionTools.resolve` (D3/D4) is LATENT (zero production callers at HEAD); the real bypass is BatchTool inner dispatch (`tool/batch.ts:104`) which skips `Plugin.trigger`; all active dispatchers live in 3 files. Also flags a pre-existing security gap: the server-v2 `shell` HTTP route has no permission check. - `packages/opencode/test/altimate/defork/route-sentinels.test.ts` — an executable sentinel per active route, driving a harmless tool through the real dispatch code and asserting execution reaches the documented line. Test Plan: - `bun test test/altimate/defork/route-sentinels.test.ts`: 9 pass, 0 fail. Co-Authored-By: Claude Fable 5 --- docs/internal/defork-route-matrix.md | 112 ++++++ .../altimate/defork/route-sentinels.test.ts | 345 ++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 docs/internal/defork-route-matrix.md create mode 100644 packages/opencode/test/altimate/defork/route-sentinels.test.ts diff --git a/docs/internal/defork-route-matrix.md b/docs/internal/defork-route-matrix.md new file mode 100644 index 000000000..bcefbaab1 --- /dev/null +++ b/docs/internal/defork-route-matrix.md @@ -0,0 +1,112 @@ +# De-fork spike — S2 execution-route trace + bypass matrix + +Status: draft for S3 handoff. Analysis-only; no product code was modified to +produce this document. Governed by S2 of `docs/internal/2026-07-18-defork-spike-spec.md` +(rev 4, Codex-consensus-approved). + +## Purpose + +S3 will build **HardPolicy**, a pure/synchronous/total deny-gate placed at +every point where a resolved `Tool.Info` actually gets `.execute(args, ctx)`'d. +This document separates **ingress surfaces** (how a request enters the system) +from **execution dispatchers** (where a tool call is actually dispatched), and +records, for every dispatcher, whether it is currently reachable in production +(`active`), wired but with no current caller (`latent`), or intentionally +outside HardPolicy's model-invoked boundary (`out-of-scope`). + +Every `active` route below has an executable sentinel test in +`packages/opencode/test/altimate/defork/route-sentinels.test.ts` proving +execution actually reaches the cited dispatch line. `latent` routes have a +guard test that is designed to fail the moment the route gains a real caller +(signalling that its classification must be revisited). + +## Ingress surfaces (not dispatchers) + +These are how a tool-invoking request enters the system. None of them +directly calls `tool.execute(...)`; each ultimately funnels into one of the +dispatchers in the next section. + +| Surface | Entry point | Feeds dispatcher | +|---|---|---| +| CLI `run` | `src/cli/cmd/run.ts` → SDK/server call | `resolveTools` (via server route → `SessionPrompt.prompt`/`loop`) | +| TUI | `src/cli/tui/worker.ts` — starts an in-process `Server` and calls its fetch handler directly (in-process `rpc.fetch`, no real socket) | same server routes as any HTTP client → `resolveTools` | +| Server v1 (Hono) | `src/server/routes/session.ts` | `SessionPrompt.Service` → `resolveTools` | +| Server v2 (`HttpApi`) | `src/server/routes/instance/httpapi/handlers/session.ts` | `SessionPrompt.Service` → `resolveTools` (except `shell`, see below) | +| ACP | `src/acp/service.ts:511-526` (`ACP.prompt`) calls `input.sdk.session.prompt(...)` — an SDK/HTTP client call, structurally identical to CLI `run`. Exhaustive grep across all 12 files in `src/acp/` found **zero** `.execute(`, `ToolRegistry`, or `resolveTools` references. | server routes → `resolveTools` | +| GitHub / GitLab handlers | call `SessionPrompt.prompt` in-process | `resolveTools` | + +`src/server/routes/instance/httpapi/server.ts:236` (`SessionPrompt.node`), and +`src/server/routes/instance/httpapi/groups/session.ts` (payload type +definitions only) were both inspected and confirmed to add no dispatch +points of their own — pure Effect-layer/type wiring. +`src/server/routes/experimental.ts` contains only a stale comment; no live +route. + +## Execution dispatchers + +| # | Dispatcher | File:line | `ctx.ask` wiring | Ruleset | `Plugin.trigger` before/after | Reachable under allow-all | State | +|---|---|---|---|---|---|---|---| +| D1 | `SessionPrompt.resolveTools` — registry-tools loop | `src/session/prompt.ts:1521` (fn), context/`ask` at `1562-1572`, dispatch `await AppRuntime.runPromise(item.execute(args, ctx))` at `1601`, source-stamp `1614` | Real: `PermissionNext.ask({..., ruleset})` wrapped in `Effect.promise` | `PermissionNext.merge(input.agent.permission, input.session.permission ?? [])` | Yes / Yes (`1589-1599`, `1615-1624`) | Yes — this is the primary, always-reachable per-turn tool dispatch path (called from `loop()` at `918`) | **active** | +| D2 | `SessionPrompt.resolveTools` — MCP-tools loop | `src/session/prompt.ts:1630-1739`, dispatch `const result = await execute(args, opts)` at `1669` | Real, but wildcard: `AppRuntime.runPromise(ctx.ask({permission: key, patterns: ["*"], always: ["*"]}))` at `1656-1667` — always-allow-with-audit-record, not a fine-grained deny-capable gate. Comment documents a **previously fixed bug**: `await ctx.ask(...)` alone only awaited the Effect *object*, never ran `PermissionNext.ask` — MCP tools executed with no permission check until this was corrected (`upstream_fix` marker). | Same merge as D1 (wildcard pattern) | Yes / Yes (`1644-1654`, `1671-1680`) | Yes, if any MCP server is configured | **active** | +| D3 | `SessionTools.resolve` — registry-tools loop | `src/session/tools.ts:27` (fn), context/`ask` at `44-75`, dispatch `yield* item.execute(args, ctx)` at `97` | Real: `permission.ask({..., ruleset}).pipe(Effect.orDie)` at `66-74` | `Permission.merge(input.agent.permission, input.session.permission ?? [])` | Yes / Yes (`92-96`, `109-113`) | N/A — **zero production callers**. Exhaustive grep for `SessionTools` outside its own definition file returns only two doc-comments in `src/session/prompt.ts` (noting the shared `stampRegistryToolSource`/`describeMcpTool` helpers) and two doc-comment references in `src/altimate/tool-source.ts`. No call site exists at HEAD. | **latent** | +| D4 | `SessionTools.resolve` — MCP-tools loop | `src/session/tools.ts:124-216`, dispatch `Effect.promise(() => execute(args, opts))` at `145` | Real (with same wildcard-pattern caveat as D2) at `144` | Same merge as D3 | Yes / Yes (`138-142`, `156-160`) | N/A, same as D3 | **latent** (bundled with D3 — same unused resolver) | +| D5 | `BatchTool` inner dispatch | `src/tool/batch.ts:104` — `await AppRuntime.runPromise(tool.execute(validatedParams, toEffectContext(ctx, partID)))` | **Forwarded, not independently gated.** `toEffectContext` (`batch.ts:16-28`) passes the *same* `ctx.ask` the outer dispatcher (D1/D2/D3/D4/D6, whichever invoked "batch") supplied — BatchTool adds no wrapper-level, tool-specific `ask` of its own. Per-inner-tool permission enforcement therefore depends entirely on whether that inner tool's own implementation calls `ctx.ask` internally (many built-ins, e.g. `bash`, do). | Same ruleset as whichever outer dispatcher invoked "batch" (forwarded, not re-derived) | **No** — confirmed by full read of `tool/batch.ts`: no `Plugin.trigger` call anywhere in the file. `stampRegistryToolSource` is also never applied to inner results. This is the genuine, precisely-scoped bypass: batched inner tool calls skip plugin hooks and source-stamping, not permission checks outright. | Yes — the model can invoke `batch` from any agent that has it enabled, and can batch any tool present in `ToolRegistry.tools(...)` except `batch` itself (`DISALLOWED`, `batch.ts:11`) | **active** | +| D6 | Direct Task-tool dispatch (subtask replay/continuation) | `src/session/prompt.ts:517-625` inside `loop()`; `Plugin.trigger("tool.execute.before", ...)` at `580-588`, dispatch `await AppRuntime.runPromise(taskTool.execute(taskArgs, taskCtx))` at `625`, `Plugin.trigger("tool.execute.after", ...)` at `636-644` | Real: `PermissionNext.ask({..., ruleset})` at `612-621` | `PermissionNext.merge(taskAgent.permission, session.permission ?? [])` | Yes / Yes | Yes — fires whenever a pending `task`-type item is popped off the session's task queue (subtask continuation), independent of the current turn's LLM tool_calls | **active** | +| D7 | `TaskTool`'s own recursive prompt call | `src/tool/task.ts` → recursively invokes `SessionPrompt.prompt` for the spawned subagent session | N/A — delegates to D1/D2 for the child session's own tool loop | Child session's own agent/session ruleset | N/A at this layer (delegates) | Yes, whenever `task`/subagent tool is invoked | **active** (not a distinct dispatch primitive — routes back into D1/D2 for the child session) | +| D8 | CLI `debug agent --tool ` handler | `src/cli/cmd/debug/agent.handler.ts:60` — `const result = yield* tool.execute(params, toolCtx)` | Real: `ctx.ask` at `196-205` evaluates `Permission.evaluate(req.permission, pattern, ruleset)` and throws `PermissionV1.DeniedError` on `deny` | `Permission.merge(agent.permission, session.permission ?? [])` (`186`) | **No** — full-file read confirms zero `Plugin` import/usage in `agent.handler.ts`. Inconsistent with every other dispatcher above. | Only reachable via the `opencode debug agent` developer CLI command, not via any model-facing surface | **active, but out-of-scope** per the spec's "arbitrary code outside HardPolicy's model-invoked boundary" carve-out — it is a human-operated debug utility, not something the model can trigger. Flagged here because the missing `Plugin.trigger` is a real inconsistency worth fixing regardless of HardPolicy scope. | +| — | `SessionPrompt.shell` | `src/session/prompt.ts:2418-2664`, raw process spawn at `2580` (`spawn(shell, args, {...})`) | N/A — never touches `ToolRegistry`/`resolveTools`/any `Tool.Info.execute` | N/A | Only extensibility hook is `Plugin.trigger("shell.env", {cwd, sessionID, callID}, {env: {}})` at `2575-2579` — this is the spec's own named `out-of-scope` example, matched verbatim. | N/A | **out-of-scope** (matches spec's own example). **Flag:** the server v2 `shell` HTTP route (`src/server/routes/instance/httpapi/handlers/session.ts:367-388`) gates this with **only** `requireSession(ctx.params.sessionID)` (`371`) — a session-existence check, **no permission/Ruleset check of any kind**. Formally out of HardPolicy's model-invoked-tool boundary, but worth a security follow-up outside this spike since it is a raw-exec HTTP route with no ask-gate. | + +## Notes on legacy/compat plumbing (not distinct dispatchers) + +- `src/altimate/tool-zod-compat.ts` (`legacyDefToDef`/`legacyToInit`) converts + old Promise-based zod tool defs into the new Effect-based `Tool.Def` shape + and preserves `ctx.ask` pass-through unchanged. It does not introduce a new + dispatch point; every dispatcher above still owns the actual `.execute(...)` + call. +- `src/tool/registry.ts`'s `fromPlugin(id, def)` (plugin-registered tools) + feeds `Tool.Info` objects into `ToolRegistry.tools(...)`, so plugin-defined + tools are dispatched through whichever of D1–D6 resolved them — not a + separate dispatcher. + +## Known constraint affecting sentinel-test design + +The fork resolves builtin tool definitions **freshly per `ToolRegistry.tools()`/`.allInfos()` call** +rather than returning a mutable live singleton map (unlike upstream +OpenCode's `ToolRegistry.named()`). This breaks the classic "monkey-patch a +registered tool's `.execute`" test pattern — evidenced by two pre-existing +`.skip`'d tests in `packages/opencode/test/session/prompt.test.ts` (~line +1914) with an explanatory `altimate_change` comment. Sentinel tests below +therefore invoke each dispatcher's real code path directly (via `initTool()` +for self-contained tools like `batch`, or via the project's existing +Effect-layer test harness for registry/session-backed dispatchers) rather +than patching a previously-fetched tool instance. + +## Route → sentinel test mapping + +All test names below are verified verbatim against `route-sentinels.test.ts` +as committed (9 tests, all passing via `bun test +packages/opencode/test/altimate/defork/route-sentinels.test.ts`). + +| Dispatcher | Sentinel test(s) | +|---|---| +| D1 `resolveTools` registry loop | "D1: resolveTools reaches item.execute for a real registry tool" (real execution, via direct `SessionPrompt.resolveTools()` call) + "D1: resolveTools dispatch chokepoint is pinned at the documented line" (structural) | +| D3/D4 `SessionTools.resolve` (latent) | "D3/D4: SessionTools.resolve has no production caller (latent guard)" + "D3/D4: SessionTools.resolve dispatches a tool when invoked directly" (real execution, via direct `SessionTools.resolve()` call with a hand-built Effect layer) + "D3/D4: SessionTools.resolve dispatch chokepoints are pinned at the documented lines" (structural) | +| D5 `BatchTool` inner dispatch | "D5: BatchTool dispatches inner tool.execute without Plugin.trigger" (real execution, via `initTool(BatchTool)`) + "D5: BatchTool never calls Plugin.trigger for inner tool calls (structural bypass)" (structural) | +| D6 direct Task dispatch | "D6: direct Task dispatch chokepoints are pinned at the documented lines" (structural) + "D6: existing real-execution proof exists in test/session/prompt.test.ts" (citation of `it.instance("failed subtask preserves metadata on error tool state"`) — **partial compliance, flagged**: this route does not have a fresh, self-contained real-execution sentinel in `route-sentinels.test.ts` itself; see the compliance note in that file's D6 section for rationale (reconstructing the cited test's harness would duplicate ~250 lines of module-private test infrastructure from `prompt.test.ts` that isn't exported anywhere). Team-lead should decide whether to accept this as a documented S2 exception or require a follow-up to export a shared harness. | + +**D2 (`resolveTools` MCP-tools loop) is intentionally absent from this table.** +D2 shares its outer function (`resolveTools`), `context()` closure, and +`Plugin.trigger`/`ask` wiring pattern with D1 — the two loops differ only in +tool source (registry vs. MCP-discovered) and in D2's wildcard-pattern `ask` +call (`ctx.ask({permission: key, patterns: ["*"], always: ["*"]})`, see D2's +row above). D1's real-execution test already exercises `resolveTools`'s +shared machinery — permission merge, `ctx` construction, `Plugin.trigger` +before/after, result stamping — end to end; the only D2-specific code that +proof does *not* touch is the MCP-loop's own dispatch line (`1669`) and its +wildcard `ask` call (`1656-1667`). Exercising that branch for real would +require standing up a real or fake MCP server as a tool source (the same +`MCP.Service` faking technique used for D3/D4 would work, feeding one +synthetic MCP tool through `resolveTools`), which was judged disproportionate +for what is structurally the same dispatch pattern as D1 with a different +tool origin. This gap is flagged explicitly, not silently omitted, as a +candidate follow-up if S3 wants dedicated D2 coverage. diff --git a/packages/opencode/test/altimate/defork/route-sentinels.test.ts b/packages/opencode/test/altimate/defork/route-sentinels.test.ts new file mode 100644 index 000000000..8d883f06f --- /dev/null +++ b/packages/opencode/test/altimate/defork/route-sentinels.test.ts @@ -0,0 +1,345 @@ +// De-fork spike S2 — execution-route sentinels. +// +// Each `active` dispatcher documented in docs/internal/defork-route-matrix.md +// gets a real-execution test here (driving a harmless tool through the real +// dispatch code path and asserting execution actually reached it), or — where +// full real execution is impractical to set up without a heavy production +// harness — a structural/source-text regression sentinel that pins the exact +// dispatch chokepoint plus a citation of an existing, currently-passing +// real-execution test that already proves the route works end to end. +// `latent` dispatchers get a reachability guard designed to fail the moment +// the route gains a real production caller. +// +// This file is analysis/test-only. No product code is modified here. + +import { test, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { readFileSync } from "fs" +import { join } from "path" +import { Instance } from "../../../src/project/instance" +import { tmpdir } from "../../fixture/fixture" +import { ModelID, ProviderID } from "@/provider/schema" +import { MessageID } from "../../../src/session/schema" +import { BatchTool } from "../../../src/tool/batch" +import { InvalidTool } from "@/tool/invalid" +import { initTool, toolInfo } from "../tool-fixture" +import { Plugin } from "@/plugin" +import { Permission } from "@/permission" +import { ToolRegistry } from "@/tool/registry" +import { MCP } from "@/mcp" +import { Truncate } from "@/tool/truncate" +import { SessionTools } from "../../../src/session/tools" + +const SRC = join(import.meta.dir, "..", "..", "..", "src") + +function readSrc(relPath: string): string { + return readFileSync(join(SRC, relPath), "utf8") +} + +// --------------------------------------------------------------------------- +// D1 — SessionPrompt.resolveTools, registry-tools loop (src/session/prompt.ts) +// --------------------------------------------------------------------------- + +test("D1: resolveTools reaches item.execute for a real registry tool", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { Session } = await import("../../../src/session") + const { SessionPrompt } = await import("../../../src/session/prompt") + const { SessionProcessor } = await import("../../../src/session/processor") + + const session = await Session.create({}) + const providerID = ProviderID.make("test") + const modelID = ModelID.make("test-model") + const assistantID = MessageID.ascending() + + const assistantMessage = await Session.updateMessage({ + id: assistantID, + sessionID: session.id, + role: "assistant" as const, + time: { created: Date.now() }, + parentID: MessageID.ascending(), + modelID, + providerID, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: tmp.path, root: tmp.path }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + const processor = SessionProcessor.create({ + assistantMessage: assistantMessage as any, + sessionID: session.id, + model: { providerID, api: { id: modelID } } as any, + abort: new AbortController().signal, + }) + + const input: any = { + // `permission` is a PermissionV1.Ruleset (array of rules), NOT a bare object — + // Permission.evaluate() does `rulesets.flat().findLast(...)` (src/permission/index.ts:43-53), + // so passing `{}` instead of `[]` makes `rule.permission`/`rule.pattern` undefined and + // crashes Wildcard.match() deep inside Skill.available (src/skill/index.ts:379), which + // resolveTools's real ToolRegistry.tools() call transitively reaches. + agent: { name: "build", mode: "primary", permission: [], options: {} }, + model: { providerID, api: { id: modelID } }, + session: { ...session, permission: [] }, + processor, + bypassAgentCheck: true, + messages: [], + } + + const tools = await SessionPrompt.resolveTools(input) + expect(Object.keys(tools)).toContain("invalid") + + const result = await tools["invalid"].execute!( + { tool: "invalid", error: "sentinel-probe" }, + { toolCallId: "call_1", messages: [] } as any, + ) + expect((result as any).output).toContain("The arguments provided to the tool are invalid: sentinel-probe") + }, + }) +}) + +test("D1: resolveTools dispatch chokepoint is pinned at the documented line", () => { + const src = readSrc("session/prompt.ts") + const lines = src.split("\n") + // 1-indexed line numbers to match docs/internal/defork-route-matrix.md. + expect(lines[1600]).toContain("const result = await AppRuntime.runPromise(item.execute(args, ctx))") + expect(lines[1613]).toContain("const stamped = stampRegistryToolSource(output, item)") +}) + +// --------------------------------------------------------------------------- +// D3/D4 — SessionTools.resolve, registry + MCP loops (src/session/tools.ts) +// Documented as `latent`: wired but with zero production callers at HEAD. +// --------------------------------------------------------------------------- + +test("D3/D4: SessionTools.resolve has no production caller (latent guard)", () => { + // If this ever finds a real call site outside src/session/tools.ts itself + // (not a doc-comment mentioning the name), D3/D4's classification must be + // revisited from `latent` to `active` and this test updated accordingly. + const promptSrc = readSrc("session/prompt.ts") + const toolSourceSrc = readSrc("altimate/tool-source.ts") + + // Known, expected occurrences: doc-comments only, never a call. + const promptMatches = [...promptSrc.matchAll(/SessionTools/g)] + expect(promptMatches).toHaveLength(2) + for (const match of promptMatches) { + const lineStart = promptSrc.lastIndexOf("\n", match.index) + 1 + const line = promptSrc.slice(lineStart, promptSrc.indexOf("\n", match.index)) + expect(line.trim().startsWith("//")).toBe(true) + expect(line).not.toContain("SessionTools.resolve(") + } + + const toolSourceMatches = [...toolSourceSrc.matchAll(/SessionTools/g)] + expect(toolSourceMatches).toHaveLength(2) + for (const match of toolSourceMatches) { + const lineStart = toolSourceSrc.lastIndexOf("\n", match.index) + 1 + const line = toolSourceSrc.slice(lineStart, toolSourceSrc.indexOf("\n", match.index)) + expect(line.trim().startsWith("*")).toBe(true) + expect(line).not.toContain("SessionTools.resolve(") + } +}) + +test("D3/D4: SessionTools.resolve dispatches a tool when invoked directly", async () => { + await Instance.provide({ + directory: "/tmp", + fn: async () => { + const invalidInfo = await toolInfo(InvalidTool) + const invalidDef = await Effect.runPromise(invalidInfo.init({ agent: undefined as any })) + + const layer = Layer.mergeAll( + Layer.succeed( + Plugin.Service, + Plugin.Service.of({ + trigger: (_name, _input, output) => Effect.succeed(output), + list: () => Effect.succeed([]), + init: () => Effect.void, + }), + ), + Layer.succeed( + Permission.Service, + Permission.Service.of({ + ask: () => Effect.void, + reply: () => Effect.void, + list: () => Effect.succeed([]), + }), + ), + Layer.succeed( + ToolRegistry.Service, + ToolRegistry.Service.of({ + ids: () => Effect.succeed(["invalid"]), + allInfos: () => Effect.succeed([]), + register: () => Effect.void, + tools: () => + Effect.succeed([ + { + id: "invalid", + registrySource: "builtin", + description: invalidDef.description, + parameters: invalidDef.parameters, + execute: invalidDef.execute, + } as any, + ]), + }), + ), + Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: {} }), + connect: () => Effect.void, + disconnect: () => Effect.void, + remove: () => Effect.void, + getPrompt: () => Effect.die(new Error("not implemented")), + } as any), + ), + Layer.succeed( + Truncate.Service, + Truncate.Service.of({ + cleanup: () => Effect.void, + write: () => Effect.succeed(""), + output: (content: string) => Effect.succeed({ content, truncated: false as const }), + limits: () => Effect.succeed({ maxLines: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER }), + }), + ), + ) + + const fakeProcessor = { + message: { id: "msg_fake" } as any, + updateToolCall: () => Effect.succeed(undefined), + completeToolCall: () => Effect.void, + } + + const input: any = { + agent: { name: "build", mode: "primary", permission: {}, options: {} }, + model: { providerID: "test", api: { id: "test-model" } }, + session: { id: "ses_fake", permission: [] }, + processor: fakeProcessor, + bypassAgentCheck: true, + messages: [], + promptOps: {}, + } + + const tools = await Effect.runPromise(SessionTools.resolve(input).pipe(Effect.provide(layer))) + expect(Object.keys(tools)).toContain("invalid") + + const result = await tools["invalid"].execute!( + { tool: "invalid", error: "sentinel-probe" }, + { toolCallId: "call_1", messages: [] } as any, + ) + expect((result as any).output).toContain("The arguments provided to the tool are invalid: sentinel-probe") + }, + }) +}) + +test("D3/D4: SessionTools.resolve dispatch chokepoints are pinned at the documented lines", () => { + const src = readSrc("session/tools.ts") + const lines = src.split("\n") + expect(lines[96]).toContain("const result = yield* item.execute(args, ctx)") + expect(lines[144]).toContain("return yield* Effect.promise(() => execute(args, opts))") +}) + +// --------------------------------------------------------------------------- +// D5 — BatchTool inner dispatch (src/tool/batch.ts) +// --------------------------------------------------------------------------- + +test("D5: BatchTool dispatches inner tool.execute without Plugin.trigger", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { Session } = await import("../../../src/session") + const session = await Session.create({}) + + const providerID = ProviderID.make("test") + const modelID = ModelID.make("test-model") + const assistantID = MessageID.ascending() + + const msg = await Session.updateMessage({ + id: assistantID, + sessionID: session.id, + role: "assistant" as const, + time: { created: Date.now() }, + parentID: MessageID.ascending(), + modelID, + providerID, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: tmp.path, root: tmp.path }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + const batch = await initTool(BatchTool) + const result = await batch.execute( + { + tool_calls: [{ tool: "invalid", parameters: { tool: "invalid", error: "sentinel-probe" } }], + }, + { + sessionID: session.id, + messageID: msg.id, + agent: "build", + abort: new AbortController().signal, + messages: [], + }, + ) + expect(result.output).toContain("All 1 tools executed successfully") + expect((result.metadata as any).details[0].success).toBe(true) + }, + }) +}) + +test("D5: BatchTool never calls Plugin.trigger for inner tool calls (structural bypass)", () => { + const src = readSrc("tool/batch.ts") + expect(src).not.toContain("Plugin.trigger(") + expect(src).toContain("await AppRuntime.runPromise(tool.execute(validatedParams, toEffectContext(ctx, partID)))") +}) + +// --------------------------------------------------------------------------- +// D6 — direct Task-tool dispatch inside SessionPrompt.loop (src/session/prompt.ts) +// +// PARTIAL COMPLIANCE NOTE (flagged for team-lead / S3, not silently assumed): +// the S2 spec (docs/internal/2026-07-18-defork-spike-spec.md:61) requires "a +// harmless sentinel tool invocation driven end-to-end through that route" for +// every `active` dispatcher, and states "An unproven `active` route fails the +// S2 gate." The spec text does NOT itself carve out a citation-based fallback +// — that is a pragmatic judgment call made here, not a quoted spec allowance. +// +// Rationale: real end-to-end execution of this exact chokepoint IS already +// proven by the existing, currently-passing test "failed subtask preserves +// metadata on error tool state" (test/session/prompt.test.ts, `it.instance` +// block at line 806), which drives a real subtask through prompt.loop() and +// asserts on the resulting tool-state metadata that only D6's taskCtx wiring +// produces. Reconstructing an equivalent from scratch here would require +// duplicating ~250 lines of module-private test harness from that file +// (`it.instance`, `useServerConfig`, `TestLLMServer`, `addSubtask` — none of +// which are exported) for a single additional route. Given that cost, this +// route is covered by citation of that real test plus a structural +// regression sentinel pinning the exact dispatch line numbers, so any +// refactor that moves the dispatch or renames the cited test breaks CI here +// too. If stricter literal compliance is required, the fix is either to +// export a shared harness from prompt.test.ts, or to accept this as a +// documented S2 exception — team-lead's call. +// --------------------------------------------------------------------------- + +test("D6: direct Task dispatch chokepoints are pinned at the documented lines", () => { + const src = readSrc("session/prompt.ts") + const lines = src.split("\n") + expect(lines[580]).toContain('"tool.execute.before",') + expect(lines[617]).toContain("ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),") + expect(lines[624]).toContain("const result = await AppRuntime.runPromise(taskTool.execute(taskArgs, taskCtx))") + expect(lines[636]).toContain('"tool.execute.after",') +}) + +test("D6: existing real-execution proof exists in test/session/prompt.test.ts", () => { + const promptTestSrc = readFileSync(join(SRC, "..", "test", "session", "prompt.test.ts"), "utf8") + expect(promptTestSrc).toContain('it.instance("failed subtask preserves metadata on error tool state"') +}) From 970c043d12a94e2eca8de91a749dd628206c1520 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 18 Jul 2026 22:50:09 -0700 Subject: [PATCH 2/4] test: [de-fork S2] make route-sentinel pins line-number & arg-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D1/D3/D4/D6 structural pins asserted the dispatch chokepoint at a hardcoded line index and with the exact arg name. S3's HardPolicy insertions shift those lines and rename the dispatched args to `finalArgs` (it must check the post-`tool.execute.before` args), which would break these pins once S3 lands. Rewrite them to assert the chokepoint pattern occurs exactly once, matching both `args` and `finalArgs`, so this file passes identically with or without S3 applied — mergeable in any order. Co-Authored-By: Claude Fable 5 --- .../altimate/defork/route-sentinels.test.ts | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/opencode/test/altimate/defork/route-sentinels.test.ts b/packages/opencode/test/altimate/defork/route-sentinels.test.ts index 8d883f06f..b502e266e 100644 --- a/packages/opencode/test/altimate/defork/route-sentinels.test.ts +++ b/packages/opencode/test/altimate/defork/route-sentinels.test.ts @@ -103,12 +103,15 @@ test("D1: resolveTools reaches item.execute for a real registry tool", async () }) }) -test("D1: resolveTools dispatch chokepoint is pinned at the documented line", () => { - const src = readSrc("session/prompt.ts") - const lines = src.split("\n") - // 1-indexed line numbers to match docs/internal/defork-route-matrix.md. - expect(lines[1600]).toContain("const result = await AppRuntime.runPromise(item.execute(args, ctx))") - expect(lines[1613]).toContain("const stamped = stampRegistryToolSource(output, item)") +test("D1: resolveTools dispatch chokepoint is present and unique", () => { + const lines = readSrc("session/prompt.ts").split("\n") + // Assert by content (exactly one occurrence), NOT a hardcoded line number, so + // insertions above the chokepoint don't break the pin. Arg-agnostic: matches + // both the upstream `item.execute(args, ctx)` and S3's `item.execute(finalArgs, + // ctx)` (S3 passes the post-`tool.execute.before` args), so this same test + // passes with or without the S3 HardPolicy changes applied. + expect(lines.filter((l) => l.includes("AppRuntime.runPromise(item.execute(") && l.includes(", ctx))")).length).toBe(1) + expect(lines.filter((l) => l.includes("stampRegistryToolSource(output, item)")).length).toBe(1) }) // --------------------------------------------------------------------------- @@ -239,11 +242,11 @@ test("D3/D4: SessionTools.resolve dispatches a tool when invoked directly", asyn }) }) -test("D3/D4: SessionTools.resolve dispatch chokepoints are pinned at the documented lines", () => { - const src = readSrc("session/tools.ts") - const lines = src.split("\n") - expect(lines[96]).toContain("const result = yield* item.execute(args, ctx)") - expect(lines[144]).toContain("return yield* Effect.promise(() => execute(args, opts))") +test("D3/D4: SessionTools.resolve dispatch chokepoints are present and unique", () => { + const lines = readSrc("session/tools.ts").split("\n") + // Content-based + arg-agnostic (matches `args` or S3's `finalArgs`) — see D1's note. + expect(lines.filter((l) => l.includes("yield* item.execute(") && l.includes(", ctx)")).length).toBe(1) + expect(lines.filter((l) => l.includes("Effect.promise(() => execute(") && l.includes(", opts))")).length).toBe(1) }) // --------------------------------------------------------------------------- @@ -330,13 +333,11 @@ test("D5: BatchTool never calls Plugin.trigger for inner tool calls (structural // documented S2 exception — team-lead's call. // --------------------------------------------------------------------------- -test("D6: direct Task dispatch chokepoints are pinned at the documented lines", () => { - const src = readSrc("session/prompt.ts") - const lines = src.split("\n") - expect(lines[580]).toContain('"tool.execute.before",') - expect(lines[617]).toContain("ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),") - expect(lines[624]).toContain("const result = await AppRuntime.runPromise(taskTool.execute(taskArgs, taskCtx))") - expect(lines[636]).toContain('"tool.execute.after",') +test("D6: direct Task dispatch chokepoints are present and unique", () => { + const lines = readSrc("session/prompt.ts").split("\n") + // Content-based + arg-agnostic (matches `taskArgs` or S3's `finalTaskArgs`) — see D1's note. + expect(lines.filter((l) => l.includes("PermissionNext.merge(taskAgent.permission, session.permission ?? []),")).length).toBe(1) + expect(lines.filter((l) => l.includes("AppRuntime.runPromise(taskTool.execute(") && l.includes(", taskCtx)")).length).toBe(1) }) test("D6: existing real-execution proof exists in test/session/prompt.test.ts", () => { From 26db15dfa4413d7828393e67b237525396f6065c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 19 Jul 2026 07:35:00 -0700 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20[de-fork=20S2]=20address=20review?= =?UTF-8?q?=20=E2=80=94=20correct=20dispatcher=20semantics,=20commit=20spe?= =?UTF-8?q?c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Commit the governing spec (`2026-07-18-defork-spike-spec.md`) the matrix references, so reviewers can verify the S2 requirements (Codex). - D2/D4 MCP: corrected — `PermissionNext.ask` IS deny-capable (evaluates key + `*` pattern, throws on deny, blocks on `ask`); the wildcard only removes argument-level distinctions, it isn't "always-allow" (Codex). - D5 batch: reframed as a genuine PERMISSION bypass — inner tools that don't self-call `ctx.ask` (e.g. `warehouse_remove`) escape their deny rules when reached via `batch`; this is why S3 gates each inner dispatch (Codex). - D6: noted `bypassAgentCheck=true` means the task tool's own `ctx.ask` never fires, so these executions are ungated today — the reason S3 adds a dispatcher-level check at `prompt.ts:625` (Codex). - Added the direct `ReadTool` (user-attachment) dispatches as an explicit out-of-scope entry so the audit covers every `Tool.Info.execute` site (Codex). - Included the v1 Hono `shell` endpoint in the security note; filed issue #1019 to track both v1+v2 ungated shell routes (Codex + CodeRabbit). - Fixed the runnable sentinel-test command (must run from `packages/opencode`). - Latent guard now scans the ENTIRE production src tree for `SessionTools.resolve(` callers, not just two files (Codex). Co-Authored-By: Claude Fable 5 --- docs/internal/2026-07-18-defork-spike-spec.md | 111 ++++++++++++++++++ docs/internal/defork-route-matrix.md | 18 ++- .../altimate/defork/route-sentinels.test.ts | 42 +++---- 3 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 docs/internal/2026-07-18-defork-spike-spec.md diff --git a/docs/internal/2026-07-18-defork-spike-spec.md b/docs/internal/2026-07-18-defork-spike-spec.md new file mode 100644 index 000000000..87ad0397f --- /dev/null +++ b/docs/internal/2026-07-18-defork-spike-spec.md @@ -0,0 +1,111 @@ +# De-fork Spike — Build Spec (rev 4, post-Codex rounds 1–3) + +**Date:** 2026-07-18 · **Status:** rev 4 — **APPROVED for build** (Codex consensus, 4 rounds) +**Parent:** `2026-07-18-defork-feasibility-plan.md` (strategy; this spec governs build). After S1, this spec's regenerated numbers supersede the plan's §2 census table (plan gets an addendum pointer — single source of truth per topic). +**Base:** fork `main` (worktree HEAD `8a50ec7f55`). **Critical git fact (Codex-verified): the fork's history rewrite means there is NO merge-base between fork HEAD and upstream tags** — all replay/divergence math must use explicit-base three-way semantics, never a normal merge. + +## 0. Engineering conventions (every stage) + +- **One branch + one PR per stage** (`defork/s1-baseline-replay`, …). Each PR independently mergeable; S1–S2 have **zero product-behavior risk** (tooling/docs/tests only — S1 does change CI and adds git-state tooling, which its own tests cover). Behavior changes start at S3, behind tests. +- **Mergeability gates per PR:** marker check (`analyze.ts --markers --base main --strict`) · typecheck + affected suites green · **real-run evidence pasted in PR body** (actual tool output/transcripts/screenshots) · Codex deep review of the diff, findings addressed or explicitly waived with reason · `/consensus:code-review` · PR template (`PINEAPPLE`, Summary/Test Plan/Checklist, `Closes #N`). +- **Delegation:** Fable orchestrates + final-reviews; sonnet agents implement; Codex reviews plans + diffs adversarially; DGX optional for long batteries (S6/S7) — artifacts committed from this machine. +- **Safety rails:** replay tooling uses `git merge-tree` (object-db only, no working-tree mutation). If a stage ever needs a materialized tree: detached checkout at a resolved SHA in a `mkdtemp` dir, no branch created, hooks + rerere disabled, idempotent best-effort cleanup **plus** a `cleanup` subcommand to sweep leftovers (try/finally can't survive SIGKILL). No `git stash`. +- **Progress state:** per-stage records `docs/internal/defork-progress/s.md` (avoids cross-PR conflicts on one aggregate file); orchestrator updates an aggregate only after merges. + +--- + +## S1 — Baseline metrics + replay harness (DETAILED) + +### S1.0 Shared taxonomy (used by census, divergence, replay, ratchet — one implementation) +Three buckets, computed against the **pinned upstream base tree** (`git ls-tree -r v1.17.9`), with **explicit precedence: existence in the upstream base tree wins** — a path that exists upstream is `upstream_shared` even if it sits under an approved fork root (Codex rounds 2–3: **ten** current `.opencode/` paths exist in v1.17.9 at HEAD `8a50ec7f55` — including `.opencode/.gitignore` and `.opencode/themes/.gitignore` — a dedicated unit test pins all ten and their `upstream_shared` classification; the test's oracle list is generated from `git ls-tree v1.17.9` at implementation time, not hand-copied): +1. `upstream_shared` — path exists in the upstream base tree (checked first, unconditionally). +2. `fork_owned` — not upstream, and under approved fork roots (`**/altimate/**`, `packages/drivers`, `packages/dbt-tools`, `script/upstream`, `docs/`, `.opencode/`, fork test dirs — exact list versioned in `taxonomy.ts`). +3. `fork_added_outside_boundary` — absent upstream, not under approved roots (Codex measured ~116 files / 258 marker starts currently mislabeled "shared"). **Misplacement debt**, reported separately; the ratchet blocks growth here too. + +All reported totals use this taxonomy. **S1 regenerates the headline numbers** (expect true `upstream_shared` ≈ 1,140 blocks, not 1,398) and appends an addendum to the feasibility plan. + +### S1.1 `census.ts` +- Stack-based block parser; **errors on unmatched start/end**; fixture/string-literal exclusions via an explicit per-block allowlist (file + line + reason), every applied exclusion reported in output — no whole-file allowlisting. +- Category rules in a versioned data table; conflicted/multi-category files carry **multiple labels** + an explicit `UNATTRIBUTED` bucket; totals reported both per-label and deduped (no pretending additivity). +- Output: versioned JSON envelope `{schemaVersion, generatorVersion, oursSha, oursTree, upstreamBaseSha, rules: {diffOptions…}, blocks:[{file, span, contentHash, description, upstreamFix, bucket, categories[]}], sortedDeterministically}`. +- Modes: `--json`, `--summary`, `--check` (ratchet, below), `--diff-budget --base ` (S3 gate: added non-test LOC + files touched, split `upstream_shared` vs `fork_owned`). +- Unit tests: nesting, unmatched markers, taxonomy buckets (incl. outside-boundary), allowlist reporting, multi-category, envelope determinism. + +### S1.2 Ratchet (`census.ts --check`) +Invariant: **no new marker block may appear in `upstream_shared` or `fork_added_outside_boundary` without a committed exemption** — regardless of net counts. +- **Multiset comparison** of current block set vs the committed baseline keyed by `{file, contentHash}` **with counts** (Codex round 2: duplicate identical blocks already exist, e.g. `project/project.ts:760` — plain set identity would let a removed block mask an added identical duplicate). Any identity whose count rises is a violation; per-file positive-delta and global monotonic counts remain as secondary checks. +- Exemptions: committed machine-readable `script/upstream/defork-exemptions.jsonc`, **quantity-scoped** (`{blockRef: {file, contentHash}, allowedCount, reason, approvedBy, expires?}`) — not PR-body prose. +- Wired into the existing marker-guard CI job (same invocation pattern as `analyze.ts`). + +### S1.3 `divergence.ts` +- Inputs explicit: `--upstream-base v1.17.9` (must exist locally; errors with fetch instructions), `--ours `; records both SHAs + trees in the envelope. +- Reports, per bucket (primary = `upstream_shared`): changed files, non-overlapping hunks, added/deleted lines; pinned diff options (rename detection on, whitespace policy, zero-context hunks, binary counted separately, test-paths reported separately). +- Fixture-tested diff parsing. + +### S1.4 `replay.ts` — explicit-base three-way merge (Codex contract adopted) +- Inputs: `--upstream-base v1.17.9 --ours --target v1.18.3`. Resolves + records all three SHAs/trees. +- Engine: `git merge-tree --write-tree --merge-base ` (object-db only; no worktree, no branch, invoking checkout untouched). Validated on a fixture repo first (merge-tree conflict-output parsing is version-sensitive — pin/verify git version behavior in a test). +- Counts separately: conflicted paths · textual conflict regions · modify/delete · binary · rename · submodule conflicts. Attribution joins census blocks by file, **rejecting a census file generated from a different `ours` SHA**. +- KPI language fix (parent plan edited): the replay measures **conflict surface**, not "resolution time." Resolution time becomes a separately-timed human/agent resolve-and-validate exercise run at wave ends only. +- Real first run: `v1.18.3` (fetch tag), committed to `script/upstream/baselines/2026-07-18/`. + +### S1 deliverable summary +`taxonomy.ts`, `census.ts`, `divergence.ts`, `replay.ts`, tests, baselines dir, exemptions file (empty), CI ratchet wiring, regenerated headline numbers + plan addendum. **E2E evidence:** real outputs of all three tools pasted in PR. Estimated ~800–1,100 LOC tooling+tests, zero product-behavior risk. + +--- + +## S2 — Execution-route inventory + sentinel tests (DETAILED, Codex contract adopted) + +1. **Separate ingress surfaces from execution dispatchers.** Ingress: CLI `run`, TUI, server v1 routes, server v2 HttpApi, ACP, GitHub/GitLab handlers. Dispatchers (the things HardPolicy must cover): `session/tools.ts` resolver wrapper, `session/prompt.ts` parallel resolver, **BatchTool's direct registry execution (`tool/batch.ts:104` — bypasses both wrappers)**, **direct Task dispatch in `session/prompt.ts:580`**, MCP wrapper path, plus anything discovered (debug/internal direct calls). +2. **Route states:** `active` / `latent` (e.g., `SessionTools.resolve` currently has no production consumer at HEAD — verify) / `out-of-scope` (plugin-init code, `shell.env`, server config-mutation endpoints — the "arbitrary code outside HardPolicy's model-invoked boundary" section, consistent with the parent's scope). +3. **Executable sentinel tests, not `test.todo`:** each `active` route gets a harmless sentinel tool invocation driven end-to-end through that route, asserting execution reached the dispatcher (spy/probe). An unproven `active` route fails the S2 gate. Latent routes get a "latent, becomes active if wired" note + a guard test that flips when they gain a consumer. +4. Deliverables: `docs/internal/defork-route-matrix.md` (routes × {dispatcher, file:line chain, ctx.ask?, Ruleset applied, reachable-under-allow-all}) + `test/altimate/defork/route-sentinels.test.ts` (executable). Product code untouched. + +--- + +## S3 — HardPolicy prototype (DETAILED — kill gate; Codex contract adopted) + +**Versioned rule table (v1), matching TODAY's behavior — no silent product changes (semantics pinned by Agent-level runtime-composition tests, not isolated merge tests — Codex round 2):** +- v1 scope = the current *true* hard blocks, both promoted to HardPolicy as behavior-preserving: + 1. `sql_execute` (tool ID — note `sql_execute_write` is a permission ID, not the tool) DDL block (`DROP DATABASE/SCHEMA`, `TRUNCATE`) currently enforced inside `altimate/tools/sql-execute.ts:24`. + 2. **Bash DDL deny** — `agent.ts:165` appends safety *denials* AFTER user config (deny-level, not ask; asserted by `test/altimate/carry-forward/agent-safety.test.ts:84`). +- `rm -rf` / `git push --force` remain **ask-level Ruleset** entries exactly as today (`agent.ts:154`), user-overridable. The correct current-behavior boundary (ask-tier vs deny-tier, and exactly which patterns sit in each) is pinned FIRST by new Agent-level tests that exercise the real Agent composition path (user config merged, safety denials appended) — those tests are the ground truth the HardPolicy migration must keep green. +- Any escalation to unconditional deny = a **flagged product decision for Anand**, listed in the S3 PR under "behavior changes: none (v1)" / a proposed-v2 table. +- Rule table format: `{ruleID, toolID, argExtractor, action: "deny", positiveExamples[], negativeExamples[]}` — versioned, each example an executable test case. +- **Safe-control tests:** for every route × rule, a *near-miss allow* case (e.g., `DROP TABLE` where only `DROP DATABASE` is denied; `SELECT` runs) — an implementation that denies everything fails the suite. + +**Enforcement mechanics:** +- `packages/opencode/src/altimate/policy/hard-policy.ts`: pure, synchronous, **total** — malformed args or classifier failure ⇒ deny with `policy_internal_error` (never throw, never implicit allow). +- **Placement:** at each dispatcher identified by S2 — including the Batch inner dispatch (check at the inner per-tool dispatch point, not by recursively parsing batch payloads at the outer edge) and the direct Task path. +- **Final-args snapshot:** capture the value returned by `tool.execute.before` and pass the *same snapshot* to both HardPolicy and execute. Codex-verified today's resolvers ignore the hook's `output.args` (they keep using the local variable) — S3 fixes that plumbing as part of the seam (and it's an `upstream_fix` PR candidate). +- **Denial shape:** stable `{code: "hard_policy_denied", ruleID, safeReason}`, `metadata.success=false`, model-facing text, user-visible state; assert underlying execute and `tool.execute.after` are NOT called. +- **Fail-closed init:** eager init at the app-runtime composition seam (`effect/app-runtime.ts:55` candidate); invalid init ⇒ composition failure (library code does NOT call `process.exit`); test that server readiness/session startup is unreachable after invalid init. +- **Budget (executable):** measured by `census.ts --diff-budget --base main` on the S3 branch — added non-test LOC in `upstream_shared` files ≤ **100** (reserving 50 of the parent's 150 for S7), `upstream_shared` files touched ≤ 3. Fork-owned policy-module LOC unbudgeted. +- **Kill rule:** if S2's route map can't be covered within budget → STOP, post findings; status quo remains (nothing lost). + +**E2E/visual:** scripted real session (TUI screenshot + headless transcript): `DROP DATABASE` via sql tool → denied with proper surfacing; near-miss `DROP TABLE` (allowed rule-wise) → proceeds to normal permission flow; server-API session attempt transcript. + +--- + +## S4–S10 outline (mini-spec at PR time, informed by upstream stages) + +- **S4** Prebundled AltimatePlugin skeleton (internal-plugin path, one marker; deterministic ordering; duplicate-tool-ID policy; offline test; required-init failure ⇒ composition failure, optional ⇒ loud degrade). +- **S5** 3 pilot tools via plugin (`schema-inspect`, `sql_execute`, `dbt-manifest`) with golden transcript parity incl. HardPolicy + subagent + server paths; produces the migration recipe. Golden captures created here (deferred from S1 by design — parent plan updated to match). +- **S6** Telemetry event-consumer + golden-diff battery (DGX candidate); residual call-site list. +- **S7** Validator continuation: adversarial harness vs `session.idle` (100 trials, DGX candidate); awaited `session.turn.complete` core hook within the reserved 50-LOC budget; exactly-once or validators stay fork-side. +- **S8** Separate `altimate.json` + MCP discovery via plugin; provider/auth classification. +- **S9** Branding exact-match external patch + dist leakage scan + snapshots. **Branches only after all measured stages merge** (its replay delta must reflect them); not parallel with S5. +- **S10** Decision: block-by-block carrier map, 7 gates from the parent plan, GO/NO-GO. + +**Sequencing:** S1→S2→S3 serial. Post-S3: S4→S5 serial; S6/S8 may parallel S5; S7 after S4; S9 after measured stages merge; S10 last. Anand review checkpoints: S3, S7. + +## Consensus log + +- Round 1 (Codex): CHANGES REQUIRED — 7 blockers (replay merge-base invalidity; taxonomy mismatch; ratchet identity-based detection; route matrix active/latent + Batch/Task bypasses + executable sentinels; v1 rules vs actual current behavior; enforcement/failure contract gaps incl. output.args plumbing; budget/independence executability). **All adopted in rev 2 above.** NITS adopted: golden-capture note synced to parent plan; divergence diff-option pinning; census unmatched-marker errors + allowlist reporting; multi-category; "zero product-behavior risk" wording; `agentID`/`callID` semantics to be pinned in S3 rule table (callID optional today). +- Round 2 (Codex): CHANGES REQUIRED — 3 blockers: taxonomy precedence for upstream/.opencode overlap (8 paths); multiset ratchet identity + quantity-scoped exemptions; v1 rule table wrong about bash DDL (it IS deny-level today, appended after user config) + pin semantics via Agent-level tests. **All adopted in rev 3.** +- Round 3 (Codex): CHANGES REQUIRED — 1 blocker: overlap set is 10 paths not 8 (adds `.opencode/.gitignore`, `.opencode/themes/.gitignore`). **Adopted.** Multiset ratchet + v1 rule table confirmed genuine. +### S1 build review (Codex deep review of implementation, 2026-07-18) +- Round 1 of S1 CODE review: **CHANGES REQUIRED — 16 blocking** (census read dirty FS not the pinned tree → false provenance + wrong totals; divergence `+++/---` parser ate ~10,870 real deletions; replay counted conflict messages not textual regions + "auto-merged" overcounted; literal NUL bytes in census.ts; shell-injection in refs.ts; unmatched markers silently counted not errored/allowlisted; tests blessed wrong behavior). All handed back to the builder as a fix order; corrected numbers pinned (divergence 5,283 files +499,181/−740,989; replay 466 regions across 118 files, 94 clean auto-merges). Codex CONFIRMED correct: 10-path .opencode oracle, centralized taxonomy precedence, multiset ratchet multiplicity, explicit-base replay command, oursSha rejection, CI ordering. +- Round 2 of S1 CODE review: PENDING after fixes. + +- Round 4 (Codex): **CONSENSUS: APPROVED** (2026-07-18). Watch-items for S1 builder: (1) overlap oracle generated from `git ls-tree -r v1.17.9`, both `.gitignore` paths verified `upstream_shared`; (2) taxonomy precedence centralized in `taxonomy.ts`, reused unchanged by census/divergence/replay/ratchet; (3) provenance + determinism — pin resolved SHAs/trees, reject census/replay SHA mismatches, multiset-count ratcheting. diff --git a/docs/internal/defork-route-matrix.md b/docs/internal/defork-route-matrix.md index bcefbaab1..f60556cd9 100644 --- a/docs/internal/defork-route-matrix.md +++ b/docs/internal/defork-route-matrix.md @@ -47,14 +47,15 @@ route. | # | Dispatcher | File:line | `ctx.ask` wiring | Ruleset | `Plugin.trigger` before/after | Reachable under allow-all | State | |---|---|---|---|---|---|---|---| | D1 | `SessionPrompt.resolveTools` — registry-tools loop | `src/session/prompt.ts:1521` (fn), context/`ask` at `1562-1572`, dispatch `await AppRuntime.runPromise(item.execute(args, ctx))` at `1601`, source-stamp `1614` | Real: `PermissionNext.ask({..., ruleset})` wrapped in `Effect.promise` | `PermissionNext.merge(input.agent.permission, input.session.permission ?? [])` | Yes / Yes (`1589-1599`, `1615-1624`) | Yes — this is the primary, always-reachable per-turn tool dispatch path (called from `loop()` at `918`) | **active** | -| D2 | `SessionPrompt.resolveTools` — MCP-tools loop | `src/session/prompt.ts:1630-1739`, dispatch `const result = await execute(args, opts)` at `1669` | Real, but wildcard: `AppRuntime.runPromise(ctx.ask({permission: key, patterns: ["*"], always: ["*"]}))` at `1656-1667` — always-allow-with-audit-record, not a fine-grained deny-capable gate. Comment documents a **previously fixed bug**: `await ctx.ask(...)` alone only awaited the Effect *object*, never ran `PermissionNext.ask` — MCP tools executed with no permission check until this was corrected (`upstream_fix` marker). | Same merge as D1 (wildcard pattern) | Yes / Yes (`1644-1654`, `1671-1680`) | Yes, if any MCP server is configured | **active** | +| D2 | `SessionPrompt.resolveTools` — MCP-tools loop | `src/session/prompt.ts:1630-1739`, dispatch `const result = await execute(args, opts)` at `1669` | Real and **deny-capable**: `AppRuntime.runPromise(ctx.ask({permission: key, patterns: ["*"], always: ["*"]}))` at `1656-1667`. Correction: `PermissionNext.ask` DOES evaluate the tool key + `"*"` pattern against the merged ruleset — it throws on an explicit `deny` and blocks for a reply when the result is `ask` (`src/permission/next.ts:138-168`). The `always: ["*"]` field only persists approval patterns after an `always` reply; the wildcard `patterns` merely prevents *argument-level* distinctions — it does NOT make the gate non-deny-capable. Comment documents a **previously fixed bug**: `await ctx.ask(...)` alone only awaited the Effect *object*, never ran `PermissionNext.ask` — MCP tools executed with no permission check until this was corrected (`upstream_fix` marker). | Same merge as D1 (wildcard pattern) | Yes / Yes (`1644-1654`, `1671-1680`) | Yes, if any MCP server is configured | **active** | | D3 | `SessionTools.resolve` — registry-tools loop | `src/session/tools.ts:27` (fn), context/`ask` at `44-75`, dispatch `yield* item.execute(args, ctx)` at `97` | Real: `permission.ask({..., ruleset}).pipe(Effect.orDie)` at `66-74` | `Permission.merge(input.agent.permission, input.session.permission ?? [])` | Yes / Yes (`92-96`, `109-113`) | N/A — **zero production callers**. Exhaustive grep for `SessionTools` outside its own definition file returns only two doc-comments in `src/session/prompt.ts` (noting the shared `stampRegistryToolSource`/`describeMcpTool` helpers) and two doc-comment references in `src/altimate/tool-source.ts`. No call site exists at HEAD. | **latent** | | D4 | `SessionTools.resolve` — MCP-tools loop | `src/session/tools.ts:124-216`, dispatch `Effect.promise(() => execute(args, opts))` at `145` | Real (with same wildcard-pattern caveat as D2) at `144` | Same merge as D3 | Yes / Yes (`138-142`, `156-160`) | N/A, same as D3 | **latent** (bundled with D3 — same unused resolver) | -| D5 | `BatchTool` inner dispatch | `src/tool/batch.ts:104` — `await AppRuntime.runPromise(tool.execute(validatedParams, toEffectContext(ctx, partID)))` | **Forwarded, not independently gated.** `toEffectContext` (`batch.ts:16-28`) passes the *same* `ctx.ask` the outer dispatcher (D1/D2/D3/D4/D6, whichever invoked "batch") supplied — BatchTool adds no wrapper-level, tool-specific `ask` of its own. Per-inner-tool permission enforcement therefore depends entirely on whether that inner tool's own implementation calls `ctx.ask` internally (many built-ins, e.g. `bash`, do). | Same ruleset as whichever outer dispatcher invoked "batch" (forwarded, not re-derived) | **No** — confirmed by full read of `tool/batch.ts`: no `Plugin.trigger` call anywhere in the file. `stampRegistryToolSource` is also never applied to inner results. This is the genuine, precisely-scoped bypass: batched inner tool calls skip plugin hooks and source-stamping, not permission checks outright. | Yes — the model can invoke `batch` from any agent that has it enabled, and can batch any tool present in `ToolRegistry.tools(...)` except `batch` itself (`DISALLOWED`, `batch.ts:11`) | **active** | -| D6 | Direct Task-tool dispatch (subtask replay/continuation) | `src/session/prompt.ts:517-625` inside `loop()`; `Plugin.trigger("tool.execute.before", ...)` at `580-588`, dispatch `await AppRuntime.runPromise(taskTool.execute(taskArgs, taskCtx))` at `625`, `Plugin.trigger("tool.execute.after", ...)` at `636-644` | Real: `PermissionNext.ask({..., ruleset})` at `612-621` | `PermissionNext.merge(taskAgent.permission, session.permission ?? [])` | Yes / Yes | Yes — fires whenever a pending `task`-type item is popped off the session's task queue (subtask continuation), independent of the current turn's LLM tool_calls | **active** | +| D5 | `BatchTool` inner dispatch | `src/tool/batch.ts:104` — `await AppRuntime.runPromise(tool.execute(validatedParams, toEffectContext(ctx, partID)))` | **Forwarded, not independently gated.** `toEffectContext` (`batch.ts:16-28`) passes the *same* `ctx.ask` the outer dispatcher (D1/D2/D3/D4/D6, whichever invoked "batch") supplied — BatchTool adds no wrapper-level, tool-specific `ask` of its own. Per-inner-tool permission enforcement therefore depends entirely on whether that inner tool's own implementation calls `ctx.ask` internally (many built-ins, e.g. `bash`, do). | Same ruleset as whichever outer dispatcher invoked "batch" (forwarded, not re-derived) | **No** — confirmed by full read of `tool/batch.ts`: no `Plugin.trigger` call anywhere in the file. `stampRegistryToolSource` is also never applied to inner results. **This is also a genuine PERMISSION bypass, not merely a hooks/stamping gap:** `BatchTool` resolves every registry tool WITHOUT the current agent (`batch.ts:62`) and invokes it directly, so an inner tool that does not call `ctx.ask` internally (e.g. `warehouse_remove`, `src/altimate/tools/warehouse-remove.ts`) is executed with NO permission evaluation — a configured `warehouse_remove: deny` rule is never applied when the tool is reached via `batch`. This is exactly why S3 gates each inner dispatch independently at `batch.ts:104` rather than relying on inner tools' own `ctx.ask`. | Yes — the model can invoke `batch` from any agent that has it enabled, and can batch any tool present in `ToolRegistry.tools(...)` except `batch` itself (`DISALLOWED`, `batch.ts:11`) | **active** | +| D6 | Direct Task-tool dispatch (subtask replay/continuation) | `src/session/prompt.ts:517-625` inside `loop()`; `Plugin.trigger("tool.execute.before", ...)` at `580-588`, dispatch `await AppRuntime.runPromise(taskTool.execute(taskArgs, taskCtx))` at `625`, `Plugin.trigger("tool.execute.after", ...)` at `636-644` | `PermissionNext.ask({..., ruleset})` closure present at `612-621`, **but currently NOT exercised**: `taskCtx.extra.bypassAgentCheck` is always `true` at `src/session/prompt.ts:597`, and `TaskTool.execute` only calls `ctx.ask` when that flag is `false` (`src/tool/task.ts:96-107`). So these direct pending-subtask executions are effectively **ungated** today — which is precisely why S3 inserts a dispatcher-level `HardPolicy.check()` here (`prompt.ts:625`) rather than relying on the task tool's own ask. | `PermissionNext.merge(taskAgent.permission, session.permission ?? [])` | Yes / Yes | Yes — fires whenever a pending `task`-type item is popped off the session's task queue (subtask continuation), independent of the current turn's LLM tool_calls | **active** | | D7 | `TaskTool`'s own recursive prompt call | `src/tool/task.ts` → recursively invokes `SessionPrompt.prompt` for the spawned subagent session | N/A — delegates to D1/D2 for the child session's own tool loop | Child session's own agent/session ruleset | N/A at this layer (delegates) | Yes, whenever `task`/subagent tool is invoked | **active** (not a distinct dispatch primitive — routes back into D1/D2 for the child session) | | D8 | CLI `debug agent --tool ` handler | `src/cli/cmd/debug/agent.handler.ts:60` — `const result = yield* tool.execute(params, toolCtx)` | Real: `ctx.ask` at `196-205` evaluates `Permission.evaluate(req.permission, pattern, ruleset)` and throws `PermissionV1.DeniedError` on `deny` | `Permission.merge(agent.permission, session.permission ?? [])` (`186`) | **No** — full-file read confirms zero `Plugin` import/usage in `agent.handler.ts`. Inconsistent with every other dispatcher above. | Only reachable via the `opencode debug agent` developer CLI command, not via any model-facing surface | **active, but out-of-scope** per the spec's "arbitrary code outside HardPolicy's model-invoked boundary" carve-out — it is a human-operated debug utility, not something the model can trigger. Flagged here because the missing `Plugin.trigger` is a real inconsistency worth fixing regardless of HardPolicy scope. | -| — | `SessionPrompt.shell` | `src/session/prompt.ts:2418-2664`, raw process spawn at `2580` (`spawn(shell, args, {...})`) | N/A — never touches `ToolRegistry`/`resolveTools`/any `Tool.Info.execute` | N/A | Only extensibility hook is `Plugin.trigger("shell.env", {cwd, sessionID, callID}, {env: {}})` at `2575-2579` — this is the spec's own named `out-of-scope` example, matched verbatim. | N/A | **out-of-scope** (matches spec's own example). **Flag:** the server v2 `shell` HTTP route (`src/server/routes/instance/httpapi/handlers/session.ts:367-388`) gates this with **only** `requireSession(ctx.params.sessionID)` (`371`) — a session-existence check, **no permission/Ruleset check of any kind**. Formally out of HardPolicy's model-invoked-tool boundary, but worth a security follow-up outside this spike since it is a raw-exec HTTP route with no ask-gate. | +| — | `SessionPrompt.shell` | `src/session/prompt.ts:2418-2664`, raw process spawn at `2580` (`spawn(shell, args, {...})`) | N/A — never touches `ToolRegistry`/`resolveTools`/any `Tool.Info.execute` | N/A | Only extensibility hook is `Plugin.trigger("shell.env", {cwd, sessionID, callID}, {env: {}})` at `2575-2579` — this is the spec's own named `out-of-scope` example, matched verbatim. | N/A | **out-of-scope** (matches spec's own example). **Flag:** the server v2 `shell` HTTP route (`src/server/routes/instance/httpapi/handlers/session.ts:367-388`) gates this with **only** `requireSession(ctx.params.sessionID)` (`371`) — a session-existence check, **no permission/Ruleset check of any kind**. Formally out of HardPolicy's model-invoked-tool boundary, but worth a security follow-up outside this spike since it is a raw-exec HTTP route with no ask-gate. **The identical gap exists on the v1 Hono endpoint** `POST /session/:sessionID/shell` (`src/server/routes/session.ts:1004-1034`), whose handler also calls `SessionPrompt.shell` directly with no permission/Ruleset check — both routes reach the same process spawn and belong in the same follow-up (tracked: **issue #1019**). | +| — | Direct `ReadTool` dispatch (user attachments) | `src/session/prompt.ts` — `createUserMessage` initializes + executes `ReadTool` for text/directory file parts (~`:2001`, `:2070`) with `ask: () => Effect.void` | N/A — ungated by design (fixed `read` tool, non-model-controlled args: the attachment paths the user supplied) | N/A | These do NOT funnel through any dispatcher above | N/A | **out-of-scope** — the `read` tool is ungoverned (no v1 HardPolicy rule targets it) and the args are user-attachment paths, not model-controlled tool_call args. Listed for completeness so the S3 audit covers every `Tool.Info.execute` site. | ## Notes on legacy/compat plumbing (not distinct dispatchers) @@ -84,8 +85,13 @@ than patching a previously-fetched tool instance. ## Route → sentinel test mapping All test names below are verified verbatim against `route-sentinels.test.ts` -as committed (9 tests, all passing via `bun test -packages/opencode/test/altimate/defork/route-sentinels.test.ts`). +as committed (9 tests, all passing). Run it package-locally (the repo-root +`bunfig.toml` points the test root at a deliberately-nonexistent dir, so it +must be invoked from `packages/opencode`): + +```bash +cd packages/opencode && bun test test/altimate/defork/route-sentinels.test.ts +``` | Dispatcher | Sentinel test(s) | |---|---| diff --git a/packages/opencode/test/altimate/defork/route-sentinels.test.ts b/packages/opencode/test/altimate/defork/route-sentinels.test.ts index b502e266e..e339ff023 100644 --- a/packages/opencode/test/altimate/defork/route-sentinels.test.ts +++ b/packages/opencode/test/altimate/defork/route-sentinels.test.ts @@ -120,30 +120,26 @@ test("D1: resolveTools dispatch chokepoint is present and unique", () => { // --------------------------------------------------------------------------- test("D3/D4: SessionTools.resolve has no production caller (latent guard)", () => { - // If this ever finds a real call site outside src/session/tools.ts itself - // (not a doc-comment mentioning the name), D3/D4's classification must be - // revisited from `latent` to `active` and this test updated accordingly. - const promptSrc = readSrc("session/prompt.ts") - const toolSourceSrc = readSrc("altimate/tool-source.ts") - - // Known, expected occurrences: doc-comments only, never a call. - const promptMatches = [...promptSrc.matchAll(/SessionTools/g)] - expect(promptMatches).toHaveLength(2) - for (const match of promptMatches) { - const lineStart = promptSrc.lastIndexOf("\n", match.index) + 1 - const line = promptSrc.slice(lineStart, promptSrc.indexOf("\n", match.index)) - expect(line.trim().startsWith("//")).toBe(true) - expect(line).not.toContain("SessionTools.resolve(") - } - - const toolSourceMatches = [...toolSourceSrc.matchAll(/SessionTools/g)] - expect(toolSourceMatches).toHaveLength(2) - for (const match of toolSourceMatches) { - const lineStart = toolSourceSrc.lastIndexOf("\n", match.index) + 1 - const line = toolSourceSrc.slice(lineStart, toolSourceSrc.indexOf("\n", match.index)) - expect(line.trim().startsWith("*")).toBe(true) - expect(line).not.toContain("SessionTools.resolve(") + // Scan the ENTIRE production src tree (not just prompt.ts + tool-source.ts): + // a future caller added in ANY source file must flip D3/D4 from `latent` to + // `active` so HardPolicy coverage there is re-verified. We look for a real + // `SessionTools.resolve(` CALL on a non-comment line, excluding the resolver's + // own definition file (session/tools.ts) where the name is declared. + const glob = new Bun.Glob("**/*.ts") + const callers: string[] = [] + for (const rel of glob.scanSync({ cwd: SRC })) { + if (rel === join("session", "tools.ts")) continue // the definition itself + const src = readFileSync(join(SRC, rel), "utf8") + if (!src.includes("SessionTools.resolve(")) continue + src.split("\n").forEach((line, i) => { + const t = line.trim() + const isComment = t.startsWith("//") || t.startsWith("*") || t.startsWith("/*") + if (!isComment && line.includes("SessionTools.resolve(")) callers.push(`${rel}:${i + 1}`) + }) } + // If this fails, a production caller of SessionTools.resolve appeared — + // D3/D4 are no longer latent and HardPolicy must be confirmed to gate them. + expect(callers).toEqual([]) }) test("D3/D4: SessionTools.resolve dispatches a tool when invoked directly", async () => { From 235295a65e9749050edc721324e8c1027802d289 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 19 Jul 2026 09:53:06 -0700 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20[de-fork=20S2]=20address=20review?= =?UTF-8?q?=20=E2=80=94=20precise=20coverage=20contract,=20scan=20.tsx=20c?= =?UTF-8?q?allers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - coderabbit MAJOR: the matrix claimed "every active route has an executable sentinel test", which overstates coverage — D2 (MCP resolveTools loop) has only structural pins + transitive coverage via D1, and D6 (direct Task dispatch) has structural pins + a citation of an existing prompt.test.ts test rather than a fresh self-contained sentinel. Replace the blanket claim with an explicit "Coverage contract" section that states exactly what holds: dedicated real-execution sentinels for D1 and D5; D2 and D6 as documented transitive/citation exceptions; latent-guard for D3/D4; out-of-scope routes listed for completeness. No longer reads as full dedicated-sentinel coverage. - kilo P2: the D3/D4 latent-guard's caller scan globbed `**/*.ts`, skipping the ~12 `.tsx` files under `src/` — a new `SessionTools.resolve(` caller added in a TUI `.tsx` file would slip past the guard. Broaden to `**/*.{ts,tsx}`. 9 pass. Co-Authored-By: Claude Fable 5 --- docs/internal/defork-route-matrix.md | 36 ++++++++++++++++--- .../altimate/defork/route-sentinels.test.ts | 4 ++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/internal/defork-route-matrix.md b/docs/internal/defork-route-matrix.md index f60556cd9..6a522f0e1 100644 --- a/docs/internal/defork-route-matrix.md +++ b/docs/internal/defork-route-matrix.md @@ -14,11 +14,37 @@ records, for every dispatcher, whether it is currently reachable in production (`active`), wired but with no current caller (`latent`), or intentionally outside HardPolicy's model-invoked boundary (`out-of-scope`). -Every `active` route below has an executable sentinel test in -`packages/opencode/test/altimate/defork/route-sentinels.test.ts` proving -execution actually reaches the cited dispatch line. `latent` routes have a -guard test that is designed to fail the moment the route gains a real caller -(signalling that its classification must be revisited). +### Coverage contract (read this before trusting the matrix) + +To avoid overstating HardPolicy coverage, the sentinel guarantee is stated +precisely — it is **not** "every active route has its own dedicated +real-execution sentinel." What actually holds: + +- **Dedicated executable real-execution sentinel:** **D1** and **D5** each have + a test in `route-sentinels.test.ts` that drives the real code path and proves + execution reaches the cited dispatch line. +- **Transitively covered (documented exception): D2** (`resolveTools` MCP-tools + loop). D2 shares its outer function, `context()` closure, and + `Plugin.trigger`/`ask` wiring with D1; D1's real-execution test exercises that + shared machinery end to end. The only D2-specific code NOT touched is the + MCP-loop dispatch line (`1669`) and its wildcard `ask` branch. D2 has + structural pins but **no dedicated real-execution sentinel** — see the "D2 + intentionally absent" note in the mapping section. +- **Structural + citation only (documented exception): D6** (direct Task + dispatch). Covered by line-pins plus a *citation* of an existing real-execution + test in `prompt.test.ts` (`"failed subtask preserves metadata on error tool + state"`), **not** a fresh self-contained sentinel in `route-sentinels.test.ts` + — see the D6 mapping row. +- **`latent` routes (D3/D4):** a guard test designed to fail the moment the route + gains a real caller (signalling its classification must be revisited), plus a + direct real-execution test of the resolver invoked by hand. +- **`out-of-scope` routes (D7 delegate, D8 debug CLI, `shell`, direct + `ReadTool`):** listed for completeness; not gated by HardPolicy. + +So D2 and D6 are explicitly flagged exceptions, not full dedicated-sentinel +coverage. Adding a faked-`MCP.Service` sentinel for D2 and a self-contained +harness for D6 are the two candidate follow-ups if S3 wants every active route +independently exercised. ## Ingress surfaces (not dispatchers) diff --git a/packages/opencode/test/altimate/defork/route-sentinels.test.ts b/packages/opencode/test/altimate/defork/route-sentinels.test.ts index e339ff023..310fe87c0 100644 --- a/packages/opencode/test/altimate/defork/route-sentinels.test.ts +++ b/packages/opencode/test/altimate/defork/route-sentinels.test.ts @@ -125,7 +125,9 @@ test("D3/D4: SessionTools.resolve has no production caller (latent guard)", () = // `active` so HardPolicy coverage there is re-verified. We look for a real // `SessionTools.resolve(` CALL on a non-comment line, excluding the resolver's // own definition file (session/tools.ts) where the name is declared. - const glob = new Bun.Glob("**/*.ts") + // Scan .ts AND .tsx: the TUI ships ~12 .tsx files under src/, and a new + // SessionTools.resolve( caller added in one of those must also flip D3/D4. + const glob = new Bun.Glob("**/*.{ts,tsx}") const callers: string[] = [] for (const rel of glob.scanSync({ cwd: SRC })) { if (rel === join("session", "tools.ts")) continue // the definition itself