diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index f0fd695bf13a..071ee7d9ec15 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -119,6 +119,7 @@ import type { CronToolOptions } from "./tools/cron-tool.types.js"; import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-context.js"; const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]); +const MEMORY_ISOLATION_READ_TOOL_NAMES = new Set(["memory_search", "memory_get"]); function applyModelProviderToolPolicy( toolsInput: AnyAgentTool[], @@ -951,10 +952,11 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) !options?.swarmCollector || (tool.name !== "ask_user" && tool.name !== "sessions_send" && tool.name !== "sessions_yield"), ); - // P1C has no authorized mutation or execution path. Apply this after every contributor and - // policy layer so a future core, plugin, or ring-zero tool cannot reopen a durable-write bypass. + // P1C admits only selected-plugin reads. Generic filesystem reads would let a model bypass the + // broker's subject and receipt checks, while every other contributor could reopen an egress or + // mutation path if this final surface gate moved earlier. const surfaceTools = memoryIsolationCutover - ? authorizedTools.filter((tool) => tool.name === "read") + ? authorizedTools.filter((tool) => MEMORY_ISOLATION_READ_TOOL_NAMES.has(tool.name)) : authorizedTools; if ( swarmStructuredOutputTool && diff --git a/src/agents/agent-tools.workspace-paths.test.ts b/src/agents/agent-tools.workspace-paths.test.ts index 753e62a07065..839c04c6846e 100644 --- a/src/agents/agent-tools.workspace-paths.test.ts +++ b/src/agents/agent-tools.workspace-paths.test.ts @@ -29,6 +29,7 @@ import { } from "./agent-tools.read.js"; import { createApplyPatchTool } from "./apply-patch.js"; import { createMemoryFileMutationGuard } from "./memory-file-mutation-guard.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./sandbox/constants.js"; import { resolveReadOnlyWorkspaceSkillMounts } from "./sandbox/workspace-mounts.js"; import { @@ -89,7 +90,7 @@ async function expectExecCwdResolvesTo( } describe("workspace path resolution", () => { - it("exposes only read for an enforced read-only memory agent", async () => { + it("exposes only selected authorized memory tools for an enforced read-only memory agent", async () => { await withTempDir("openclaw-memory-cutover-state-", async (stateDir) => { const originalStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = stateDir; @@ -106,8 +107,12 @@ describe("workspace path resolution", () => { .run(); resetMemoryIsolationCutoverForTest(); + vi.mocked(createOpenClawTools).mockImplementationOnce(() => + ["read", "memory_search", "memory_get", "exec"].map((name) => ({ name }) as never), + ); expect(createOpenClawCodingTools({ agentId: "main" }).map((tool) => tool.name)).toEqual([ - "read", + "memory_search", + "memory_get", ]); } finally { closeOpenClawAgentDatabasesForTest(); diff --git a/src/worker/embedded-agent.runtime.ts b/src/worker/embedded-agent.runtime.ts index 188fcfdb3cbe..64633b79b85c 100644 --- a/src/worker/embedded-agent.runtime.ts +++ b/src/worker/embedded-agent.runtime.ts @@ -157,10 +157,10 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) const omittedToolNames = permissionToolPolicy?.readOnly ? new Set(["write", "edit", "apply_patch"]) : undefined; - // P1C's selected-memory pilot exposes no mutation or execution path. The worker builds core - // tools directly, so it must apply the primary agent's final read-only surface itself. + // Workers do not host selected-memory plugin tools. Exposing their generic read tool would + // bypass the broker, so the P1C cutover gives them no filesystem or process capability. const availableToolNames = params.memoryIsolationCutover - ? (["read"] as const) + ? ([] as const) : WORKER_LOCAL_TOOL_NAMES; const activeToolNames = availableToolNames.filter( (name) => allowedToolNameSet.has(name) && !omittedToolNames?.has(name), @@ -254,7 +254,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) ); const discoveredToolNames = new Set(localTools.map((tool) => tool.name)); const requiredToolNames = [ - ...(params.memoryIsolationCutover ? ["read"] : WORKER_REQUIRED_LOCAL_TOOL_NAMES), + ...(params.memoryIsolationCutover ? [] : WORKER_REQUIRED_LOCAL_TOOL_NAMES), ...(browserRuntime ? ["browser"] : []), ]; for (const toolName of requiredToolNames) { diff --git a/src/worker/worker.runtime.test.ts b/src/worker/worker.runtime.test.ts index 05d8c03e1660..688bd014f852 100644 --- a/src/worker/worker.runtime.test.ts +++ b/src/worker/worker.runtime.test.ts @@ -969,7 +969,7 @@ describe("worker runtime", () => { ]); }); - it("keeps an enforced agent's worker tool surface read-only after state isolation", async () => { + it("keeps an enforced agent's worker tool surface free of raw filesystem tools after state isolation", async () => { const memoryStateDir = await mkdtemp(path.join(tmpdir(), "openclaw-worker-memory-state-")); const previousStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = memoryStateDir; @@ -992,9 +992,7 @@ describe("worker runtime", () => { await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" }); expect(browserRuntimeMocks.createWorkerBrowserToolRuntime).not.toHaveBeenCalled(); - expect(gateway.inferenceRequests[0]?.context.tools?.map((tool) => tool.name)).toEqual([ - "read", - ]); + expect(gateway.inferenceRequests[0]?.context.tools?.map((tool) => tool.name)).toEqual([]); } finally { resetMemoryIsolationCutoverForTest(); closeOpenClawAgentDatabasesForTest();