diff --git a/src/agent/tool-aliases.test.ts b/src/agent/tool-aliases.test.ts index 4b4a40ae5..de0c40cca 100644 --- a/src/agent/tool-aliases.test.ts +++ b/src/agent/tool-aliases.test.ts @@ -1,9 +1,14 @@ import { describe, expect, test } from "bun:test"; -import type { ToolDefinition } from "@intx/types/runtime"; +import type { ToolCall, ToolDefinition } from "@intx/types/runtime"; import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js"; import { advertisedTools } from "./tool-search.js"; import { canonicalToolName } from "./canonical-tool-name.js"; -import { advertisedToolName, WIRE_TO_ENGINE } from "./tool-aliases.js"; +import { + advertisedToolName, + authzParityDefinitions, + withAuthzParityDefinitions, + WIRE_TO_ENGINE, +} from "./tool-aliases.js"; import { evaluateApprovals } from "../permission/authz-grants.js"; const noWorkspace = { resolvedCwd: "/repo", roots: ["/repo"] }; @@ -240,3 +245,152 @@ describe("hidden alias dispatch", () => { expect(new Set(names).size).toBe(names.length); }); }); + +describe("authz parity definitions", () => { + test("run_shell gains bash and shell copies that rename name only", () => { + const native = posixDef("run_shell"); + const defs = authzParityDefinitions([native]); + expect(defs.map((d) => d.name)).toEqual(["run_shell", "bash", "shell"]); + const byName = new Map(defs.map((d) => [d.name, d])); + expect(byName.get("run_shell")).toBe(native); + expect(byName.get("bash")).toEqual({ ...native, name: "bash" }); + expect(byName.get("shell")).toEqual({ ...native, name: "shell" }); + }); + + test("read_file gains a read copy; MCP defs pass through untouched", () => { + const mcp = posixDef("mcp__linear__save_issue"); + const defs = authzParityDefinitions([posixDef("read_file"), mcp]); + expect(defs.map((d) => d.name)).toEqual([ + "read_file", + "mcp__linear__save_issue", + "read", + ]); + expect(defs.find((d) => d.name === "mcp__linear__save_issue")).toBe(mcp); + }); + + test("manage_tasks never gains an update_plan snapshot copy", () => { + const defs = authzParityDefinitions([ + posixDef("manage_tasks"), + posixDef("run_shell"), + ]); + const names = defs.map((d) => d.name); + expect(names).toContain("manage_tasks"); + expect(names).not.toContain("update_plan"); + expect(names).toContain("bash"); + expect(names).toContain("shell"); + }); + + test("already-aliased and duplicate defs dedup by name", () => { + const defs = authzParityDefinitions([ + posixDef("run_shell"), + posixDef("bash"), + posixDef("run_shell"), + ]); + expect(defs.map((d) => d.name)).toEqual(["run_shell", "bash", "shell"]); + }); + + test("empty registry stays empty", () => { + expect(authzParityDefinitions([])).toEqual([]); + }); + + test("applied to its own output is a no-op (name set stable)", () => { + const once = authzParityDefinitions([ + posixDef("run_shell"), + posixDef("read_file"), + posixDef("manage_tasks"), + posixDef("mcp__acme__do"), + ]); + const twice = authzParityDefinitions(once); + expect(twice.map((d) => d.name)).toEqual(once.map((d) => d.name)); + expect(twice).toEqual(once); + }); +}); + +describe("withAuthzParityDefinitions", () => { + test("definitions getter returns parity over the live set; run delegates", async () => { + let live: ToolDefinition[] = [posixDef("run_shell")]; + let ran: ToolCall | undefined; + const bundle = { + definitions: live, + currentDefinitions: () => live, + run: async (call: ToolCall, _signal: AbortSignal) => { + ran = call; + return { callId: call.id, content: "ok", isError: false }; + }, + addTools: () => undefined, + setCallGate: () => undefined, + }; + const wrapped = withAuthzParityDefinitions(bundle); + expect(wrapped.definitions.map((d) => d.name)).toEqual([ + "run_shell", + "bash", + "shell", + ]); + live = [...live, posixDef("mcp__acme__do")]; + expect(wrapped.definitions.map((d) => d.name)).toEqual([ + "run_shell", + "mcp__acme__do", + "bash", + "shell", + ]); + const call = { + id: "1", + name: "bash", + arguments: { command: "echo hi" }, + }; + await wrapped.run(call, new AbortController().signal); + expect(ran).toBe(call); + }); + + test("falls back to definitions when currentDefinitions is absent", () => { + const bundle = { + definitions: [posixDef("read_file")] as readonly ToolDefinition[], + run: async () => ({ callId: "1", content: "", isError: false }), + }; + expect( + withAuthzParityDefinitions(bundle).definitions.map((d) => d.name), + ).toEqual(["read_file", "read"]); + }); + + test("addTools through the wrapper is reflected in definitions plus parity copies", () => { + let live: ToolDefinition[] = [posixDef("run_shell")]; + const bundle = { + definitions: live, + currentDefinitions: () => live, + addTools: (tools: ToolDefinition[]) => { + live = [...live, ...tools]; + }, + run: async () => ({ callId: "1", content: "", isError: false }), + }; + const wrapped = withAuthzParityDefinitions(bundle); + expect(wrapped.addTools).toBe(bundle.addTools); + wrapped.addTools([posixDef("read_file")]); + expect(wrapped.definitions.map((d) => d.name)).toEqual([ + "run_shell", + "read_file", + "bash", + "shell", + "read", + ]); + }); + + test("removeTools through the wrapper is reflected in definitions", () => { + let live: ToolDefinition[] = [posixDef("run_shell"), posixDef("read_file")]; + const bundle = { + definitions: live, + currentDefinitions: () => live, + removeTools: (names: string[]) => { + live = live.filter((d) => !names.includes(d.name)); + }, + run: async () => ({ callId: "1", content: "", isError: false }), + }; + const wrapped = withAuthzParityDefinitions(bundle); + expect(wrapped.removeTools).toBe(bundle.removeTools); + wrapped.removeTools(["read_file"]); + expect(wrapped.definitions.map((d) => d.name)).toEqual([ + "run_shell", + "bash", + "shell", + ]); + }); +}); diff --git a/src/agent/tool-aliases.ts b/src/agent/tool-aliases.ts index 20f4e7f27..5bed3650e 100644 --- a/src/agent/tool-aliases.ts +++ b/src/agent/tool-aliases.ts @@ -11,6 +11,7 @@ import { type } from "arktype"; import type { ToolCall, ToolDefinition } from "@intx/types/runtime"; +import { canonicalToolName } from "./canonical-tool-name.js"; /** Advertised posix names → registry engine ids. 1:1, never dual-publish. */ export const WIRE_TO_ENGINE = { @@ -77,6 +78,67 @@ export function projectToolDefinitions( return defs.map(projectToolDefinition); } +/** + * Authz parity definitions: every def unchanged, plus one `{...def, name: + * alias}` copy for each alias in ALIAS_TO_ENGINE whose engine equals the + * def's canonical name. The reactor authz snapshot is keyed by parked wire + * name, so without these copies an ask-tier `bash`/`shell` call throws a + * wiring-defect error instead of suspending. + * + * `update_plan` is never snapshotted: its grant is create-only narrow, so no + * `update_plan`-named copy is emitted. Non-aliased defs (MCP, leaf-only) + * pass through unchanged. Output is deduplicated by name. + */ +export function authzParityDefinitions( + defs: readonly ToolDefinition[], +): ToolDefinition[] { + const seen = new Set(); + const out: ToolDefinition[] = []; + const push = (def: ToolDefinition): void => { + if (seen.has(def.name)) return; + seen.add(def.name); + out.push(def); + }; + for (const def of defs) push(def); + for (const def of defs) { + const engine = canonicalToolName(def.name); + for (const [alias, aliasEngine] of Object.entries(ALIAS_TO_ENGINE)) { + if (aliasEngine !== engine) continue; + if (alias === "update_plan") continue; + push({ ...def, name: alias }); + } + } + return out; +} + +/** + * Thin wrapper over a tool bundle (e.g. DynamicToolRunner): identical except + * the `definitions` getter returns `authzParityDefinitions` over the live + * set. Run/dispatch and mutation entry points delegate verbatim — only the + * authz-facing definition set gains parity copies. The advertised wire set + * is untouched (advertise still projects through computeAdvertised). + */ +export function withAuthzParityDefinitions< + T extends { readonly definitions: readonly ToolDefinition[] }, +>(bundle: T): T { + const wrapped = { ...bundle }; + const liveSource = bundle as Partial<{ + currentDefinitions: () => readonly ToolDefinition[]; + }>; + Object.defineProperty(wrapped, "definitions", { + get() { + const live = + typeof liveSource.currentDefinitions === "function" + ? liveSource.currentDefinitions() + : bundle.definitions; + return authzParityDefinitions(live); + }, + enumerable: true, + configurable: true, + }); + return wrapped; +} + const SHELL_WRAPPERS = new Set(["bash", "sh", "zsh"]); function shellQuote(arg: string): string { diff --git a/src/permission/reactor-authorize.test.ts b/src/permission/reactor-authorize.test.ts index 99fc830bf..d1b7a1343 100644 --- a/src/permission/reactor-authorize.test.ts +++ b/src/permission/reactor-authorize.test.ts @@ -2,12 +2,19 @@ import { expect, test } from "bun:test"; import { mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createAuthzExtension } from "@intx/inference"; +import type { + ToolDefinition, + ReactorState, + TokenUsage, +} from "@intx/types/runtime"; import { createPermissionGate } from "./gate.js"; import { createReactorAuthorize, createWorkerAuthorize, workerPermissionGate, } from "./reactor-authorize.js"; +import { authzParityDefinitions } from "../agent/tool-aliases.js"; import { runWithSubAgentIdentity, getSubAgentIdentity, @@ -335,3 +342,125 @@ test("concurrent authorization preserves each worker cwd across awaited policy e expect(seen.sort()).toEqual(["/worker-a", "/worker-b"]); expect(getSubAgentIdentity()).toBeUndefined(); }); + +const shellDef = (name: string): ToolDefinition => ({ + name, + description: `${name} tool`, + inputSchema: { type: "object", properties: {} }, +}); + +const emptyUsage = (): TokenUsage => ({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, +}); + +const askState = (): ReactorState => ({ + sessionId: "parity-ask", + turns: [], + activeForks: [], + pendingOperations: [], + activeGates: [], + tokenUsage: emptyUsage(), + lastCycleUsage: null, + lastCycleSource: null, +}); + +const askSignal = new AbortController().signal; + +test("ask-tier shell aliases suspend with a wire-named snapshot", async () => { + const policy = gate({ interactive: true }); + const ext = createAuthzExtension({ + toolDefinitions: authzParityDefinitions([ + shellDef("run_shell"), + shellDef("read_file"), + ]), + authorize: createReactorAuthorize(policy), + }); + for (const name of ["bash", "run_shell", "shell"]) { + const toolCall = namedCall(name, { command: "sleep 30" }); + const outcome = await ext.beforeTool(toolCall, askState(), askSignal); + if (outcome.type !== "suspend") throw new Error(`expected ${name} to park`); + expect(outcome.pendingOp.approvalSnapshot?.name).toBe(name); + expect(outcome.pendingOp.approvalSnapshot?.arguments).toEqual({ + command: "sleep 30", + }); + expect(outcome.pendingOp.suspendedCall).toEqual(toolCall); + } +}); + +test("seeded run_shell grant allows bash, run_shell, and shell", async () => { + const policy = gate({ interactive: true }); + policy.setSeededApprovals([{ tool: "run_shell", pattern: "echo *" }]); + const ext = createAuthzExtension({ + toolDefinitions: authzParityDefinitions([shellDef("run_shell")]), + authorize: createReactorAuthorize(policy), + }); + for (const name of ["bash", "run_shell", "shell"]) { + const outcome = await ext.beforeTool( + namedCall(name, { command: "echo hi" }), + askState(), + askSignal, + ); + expect(outcome.type).toBe("allow"); + } +}); + +test("ask on a tool missing from the resolved set still throws", async () => { + const policy = gate({ interactive: true }); + const ext = createAuthzExtension({ + toolDefinitions: authzParityDefinitions([shellDef("read_file")]), + authorize: createReactorAuthorize(policy), + }); + await expect( + ext.beforeTool( + namedCall("bash", { command: "sleep 30" }), + askState(), + askSignal, + ), + ).rejects.toThrow(/wiring defect/); +}); + +test("approved ask resumes the exact parked call once via one-shot bypass", async () => { + const policy = gate({ interactive: true }); + const reactorAuthorize = createReactorAuthorize(policy); + const seen: ToolCall[] = []; + const ext = createAuthzExtension({ + toolDefinitions: authzParityDefinitions([shellDef("run_shell")]), + authorize: (resource, action, call) => { + seen.push(call); + return reactorAuthorize(resource, action, call); + }, + }); + for (const name of ["bash", "run_shell"]) { + const toolCall = namedCall(name, { command: "sleep 30" }); + const parked = await ext.beforeTool(toolCall, askState(), askSignal); + if (parked.type !== "suspend") throw new Error(`expected ${name} to park`); + expect(parked.pendingOp.approvalSnapshot?.name).toBe(name); + ext.grantOneShot?.(toolCall.id); + const resumed = await ext.beforeTool(toolCall, askState(), askSignal); + expect(resumed.type).toBe("allow"); + expect(seen[seen.length - 1]).toEqual(parked.pendingOp.suspendedCall); + const reparked = await ext.beforeTool(toolCall, askState(), askSignal); + expect(reparked.type).toBe("suspend"); + } +}); + +test("denied shell call blocks with a policy error", async () => { + const policy = gate({ interactive: false }); + const ext = createAuthzExtension({ + toolDefinitions: authzParityDefinitions([shellDef("run_shell")]), + authorize: createReactorAuthorize(policy), + }); + for (const name of ["bash", "run_shell"]) { + const outcome = await ext.beforeTool( + namedCall(name, { command: "sleep 30" }), + askState(), + askSignal, + ); + if (outcome.type !== "block") throw new Error(`expected ${name} to block`); + expect(outcome.reason).toMatch(/Denied by policy/); + } +}); diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 228b678ea..101bc22bb 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -12,6 +12,7 @@ import type { import { withMockedModuleDuring } from "../../testkit/mock-module.js"; import type { ChatDirector } from "../agent/director.js"; +import { authzParityDefinitions } from "../agent/tool-aliases.js"; import { createAdvertisedToolset, loadSessionLocalSettings, @@ -101,6 +102,33 @@ describe("createAdvertisedToolset", () => { ).toEqual(["read"]); }); + test("parity defs cover native and wire names while the wire set stays byte-identical", () => { + const native = [ + def("run_shell"), + def("read_file"), + def("mcp__linear__save_issue"), + ]; + const parity = authzParityDefinitions(native); + const parityNames = new Set(parity.map((d) => d.name)); + for (const name of [ + "run_shell", + "read_file", + "mcp__linear__save_issue", + "bash", + "shell", + "read", + ]) { + expect(parityNames.has(name)).toBe(true); + } + expect(parityNames.has("update_plan")).toBe(false); + const { computeAdvertised } = createAdvertisedToolset(wiring()); + expect(JSON.stringify(computeAdvertised(parity))).toBe( + JSON.stringify(computeAdvertised(native)), + ); + const wireNames = computeAdvertised(parity).map((d) => d.name); + expect(new Set(wireNames).size).toBe(wireNames.length); + }); + test("isAdvertised tracks prefix, pinned, and activated names", () => { const { activated, isAdvertised } = createAdvertisedToolset( wiring({ pinnedTools: ["mcp__linear__save_issue"] }), diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 1e1cf773e..893e97145 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -49,7 +49,10 @@ import { type ActivatedToolTracker, type ToolAvailability, } from "../agent/tool-search.js"; -import { nameMatchesAdvertisedListing } from "../agent/tool-aliases.js"; +import { + nameMatchesAdvertisedListing, + withAuthzParityDefinitions, +} from "../agent/tool-aliases.js"; import { canonicalToolName } from "../agent/canonical-tool-name.js"; import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js"; import { resolveModelFamilyPolicy } from "../agent/model-family-policy.js"; @@ -612,7 +615,7 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { const toolsFactory = defineTool({ id: wiring.toolsId, definitions: [], - factory: () => wiring.getDynamicRunner(), + factory: () => withAuthzParityDefinitions(wiring.getDynamicRunner()), }); const provider = wiring.getProvider(); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index e35b395b5..9e32a82b9 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -70,6 +70,7 @@ import { canonicalToolName } from "../agent/canonical-tool-name.js"; import { advertisedToolName, projectToolDefinitions, + withAuthzParityDefinitions, } from "../agent/tool-aliases.js"; import { @@ -1126,11 +1127,11 @@ async function runSubAgentInner( tools, toolWatchdogFromSettings(params.settings), ); - return { + return withAuthzParityDefinitions({ ...runner, run: (call, signal) => withWorkerIdentity(() => runner.run(call, signal)), - }; + }); }, });