diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index cb2278bbca..cd9a6f990c 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -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; + }>; + 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", () => { diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 0f0b0fb1ae..ff1014c145 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -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, +): 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);