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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 156 additions & 2 deletions src/agent/tool-aliases.test.ts
Original file line number Diff line number Diff line change
@@ -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"] };
Expand Down Expand Up @@ -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",
]);
});
});
62 changes: 62 additions & 0 deletions src/agent/tool-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<string>();
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 {
Expand Down
129 changes: 129 additions & 0 deletions src/permission/reactor-authorize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ToolCall>({
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/);
}
});
Loading
Loading