fix: gate customTools for non-pi runtimes before createSession

Greptile P1 on PR #2011: Grok ACP (and other plugin runtimes) previously
executed engine-injected fn_* tools without the pi action-gate / permanent-
agent / RTK rewrite chain. Wrap customTools once in createResolvedAgentSession
for non-pi runtimes so loopback MCP bridges dispatch already-gated closures.
Pi still owns its own wrap chain inside createFnAgent to avoid double-wrapping.
This commit is contained in:
gsxdsm
2026-07-11 23:24:18 -07:00
parent 40a01f1d0f
commit 0b4ce01ea8
2 changed files with 181 additions and 3 deletions

View File

@@ -622,6 +622,127 @@ describe("createResolvedAgentSession", () => {
warnSpy.mockRestore();
});
/*
FNXC:GrokAcp 2026-07-12-06:30:
PR #2011 Greptile P1: non-pi runtimes must receive action-gated customTools so
Grok ACP loopback execute cannot bypass AgentPermissionPolicy.
*/
it("wraps customTools with action gate for non-pi runtimes before createSession", async () => {
const mockSession = { prompt: vi.fn() } as any;
const createSessionMock = vi.fn().mockResolvedValue({ session: mockSession });
resolveRuntimeMock.mockResolvedValue({
runtime: {
id: "grok",
name: "Grok Runtime",
createSession: createSessionMock,
promptWithFallback: vi.fn(),
describeModel: vi.fn(() => "grok/default"),
},
runtimeId: "grok",
wasConfigured: true,
});
const execute = vi.fn().mockResolvedValue({ ok: true });
const rawTool = {
name: "fn_workflow_delete",
label: "Delete Workflow",
description: "",
parameters: {},
execute,
};
const lockedDownPolicy = {
presetId: "locked-down",
rules: {
git_write: "block",
file_write_delete: "block",
command_execution: "block",
network_api: "block",
task_agent_mutation: "block",
review_gate_bypass: "block",
file_scope: "block",
},
};
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
await createResolvedAgentSession({
sessionPurpose: "executor",
cwd: "/tmp/project",
systemPrompt: "system",
customTools: [rawTool as any],
actionGateContext: {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: lockedDownPolicy as any,
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn().mockResolvedValue(null),
} as any,
});
expect(createSessionMock).toHaveBeenCalledTimes(1);
const passedTools = createSessionMock.mock.calls[0][0].customTools as Array<{
name: string;
execute: (...args: unknown[]) => Promise<unknown>;
}>;
expect(passedTools).toHaveLength(1);
expect(passedTools[0].name).toBe("fn_workflow_delete");
expect(passedTools[0].execute).not.toBe(execute);
// Gated execute must not call the raw tool when policy blocks.
const blocked = await passedTools[0].execute("call-1", { workflow_id: "WF-1" });
expect(blocked).toEqual(
expect.objectContaining({
isError: true,
}),
);
expect(execute).not.toHaveBeenCalled();
});
it("does not pre-wrap customTools for the pi runtime (createFnAgent owns the chain)", async () => {
const mockSession = { prompt: vi.fn() } as any;
const createSessionMock = vi.fn().mockResolvedValue({ session: mockSession });
resolveRuntimeMock.mockResolvedValue({
runtime: {
id: "pi",
name: "Default PI Runtime",
createSession: createSessionMock,
promptWithFallback: vi.fn(),
describeModel: vi.fn(() => "mock/model"),
},
runtimeId: "pi",
wasConfigured: false,
});
const execute = vi.fn().mockResolvedValue({ ok: true });
const rawTool = {
name: "fn_workflow_delete",
label: "Delete",
description: "",
parameters: {},
execute,
};
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
await createResolvedAgentSession({
sessionPurpose: "executor",
cwd: "/tmp/project",
systemPrompt: "system",
customTools: [rawTool as any],
actionGateContext: {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: { defaultDisposition: "block", rules: {} } as any,
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
} as any,
});
const passedTools = createSessionMock.mock.calls[0][0].customTools;
expect(passedTools[0]).toBe(rawTool);
});
});
describe("resolveMergerSessionModel", () => {

View File

@@ -10,7 +10,7 @@
import type { AgentRuntimeOptions } from "./agent-runtime.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { AgentSession } from "@earendil-works/pi-coding-agent";
import type { AgentSession, ToolDefinition } from "@earendil-works/pi-coding-agent";
import {
GROK_CLI_PROVIDER_ID,
isGrokApiKeyFusionVisible,
@@ -28,10 +28,53 @@ import {
} from "@fusion/core";
import { resolveRuntime, buildRuntimeResolutionContext, isMockProviderId, type SessionPurpose } from "./runtime-resolution.js";
import { createLogger } from "./logger.js";
import { promptWithFallback, describeModel } from "./pi.js";
import {
promptWithFallback,
describeModel,
wrapToolsWithActionGate,
wrapToolsWithPermanentAgentGating,
wrapToolsWithRtkRewrite,
} from "./pi.js";
import type { RunAuditor } from "./run-audit.js";
import { MockAgentRuntime } from "./providers/mock-provider.js";
/*
FNXC:GrokAcp 2026-07-12-06:30:
Non-pi plugin runtimes (Grok ACP, Hermes, OpenClaw, …) receive `customTools` as
engine-injected `fn_*` ToolDefinitions and dispatch them via in-process execute
(or a loopback MCP bridge). Pi applies RTK rewrite → permanent-agent gating →
action gate inside `createFnAgent` before tools reach a session; plugin runtimes
previously skipped that chain and executed raw `execute` closures (Greptile P1
on PR #2011). Apply the same policy wrappers once here for every non-pi runtime
before `runtime.createSession`, so Grok/other CLI bridges cannot bypass gate
policy. Do not wrap for `pi` — `createFnAgent` still owns that chain and must
not double-wrap. Boundary jailing stays pi-local (needs worktree paths derived
inside createFnAgent).
*/
/** Runtime ids that already wrap customTools inside their own createSession path. */
const RUNTIMES_WITH_INTERNAL_TOOL_GATING = new Set(["pi"]);
/**
* Apply Fusion tool policy wrappers for plugin runtimes that do not wrap tools
* themselves. Mirrors the customTools portion of the pi createFnAgent chain.
*/
export function wrapCustomToolsForPluginRuntime(
tools: ToolDefinition[] | undefined,
options: Pick<AgentRuntimeOptions, "actionGateContext" | "permanentAgentGating">,
): ToolDefinition[] | undefined {
if (!tools || tools.length === 0) {
return tools;
}
const withRtk = wrapToolsWithRtkRewrite(tools);
const withPermanent = wrapToolsWithPermanentAgentGating(withRtk, options.permanentAgentGating);
return wrapToolsWithActionGate(withPermanent, options.actionGateContext);
}
function shouldWrapCustomToolsForRuntime(runtimeId: string): boolean {
return !RUNTIMES_WITH_INTERNAL_TOOL_GATING.has(runtimeId);
}
/** Logger for agent session helpers */
const sessionLog = createLogger("agent-session");
const mockRuntimeSingleton = new MockAgentRuntime();
@@ -584,7 +627,21 @@ export async function createResolvedAgentSession(
// latest sync point (just before LLM session instantiation) rather than
// here, before the runtime's own awaited setup work runs. See
// AgentRuntimeOptions.beforeSpawnSession for the contract.
const result = await resolved.runtime.createSession(effectiveRuntimeOptionsWithModel);
//
// FNXC:GrokAcp 2026-07-12-06:30:
// Gate customTools for non-pi runtimes before createSession so ACP/CLI
// bridges (e.g. Grok loopback MCP) execute already-gated closures.
const sessionCreateOptions: AgentRuntimeOptions =
shouldWrapCustomToolsForRuntime(resolved.runtimeId)
? {
...effectiveRuntimeOptionsWithModel,
customTools: wrapCustomToolsForPluginRuntime(
effectiveRuntimeOptionsWithModel.customTools,
effectiveRuntimeOptionsWithModel,
),
}
: effectiveRuntimeOptionsWithModel;
const result = await resolved.runtime.createSession(sessionCreateOptions);
const testModeActive = settings ? isTestModeActive(settings) : false;
const mockProviderActive = isMockProviderId(runtimeOptions.defaultProvider);