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:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user