diff --git a/src/agents/sandbox/remote-fs-bridge.ts b/src/agents/sandbox/remote-fs-bridge.ts index 16fcafa3cf3..c88261a8ea5 100644 --- a/src/agents/sandbox/remote-fs-bridge.ts +++ b/src/agents/sandbox/remote-fs-bridge.ts @@ -97,7 +97,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); - this.ensureWritable(target, "write files"); + await this.ensureRemoteWritable(target, "write files", params.signal); const pinned = await this.resolvePinnedParent({ containerPath: target.containerPath, action: "write files", @@ -126,7 +126,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { const target = this.resolveTarget(params); - this.ensureWritable(target, "create directories"); + await this.ensureRemoteWritable(target, "create directories", params.signal); const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); if (relativePathEscapesContainerRoot(relativePath)) { throw new Error( @@ -147,7 +147,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); - this.ensureWritable(target, "remove files"); + await this.ensureRemoteWritable(target, "remove files", params.signal); const exists = await this.remotePathExists(target.containerPath, params.signal); if (!exists) { if (params.force === false) { @@ -182,6 +182,8 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const { from, to } = this.resolveRenameTargets(params); + await this.ensureRemoteWritable(from, "rename files", params.signal); + await this.ensureRemoteWritable(to, "rename files", params.signal); const fromPinned = await this.resolvePinnedParent({ containerPath: from.containerPath, action: "rename files", @@ -384,6 +386,44 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { } } + private async ensureRemoteWritable( + target: ResolvedRemotePath, + action: string, + signal?: AbortSignal, + ): Promise { + this.ensureWritable(target, action); + const protectedRoot = this.findRemoteProtectedSkillRoot(target.containerPath); + if (protectedRoot && (await this.remotePathExists(protectedRoot, signal))) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private findRemoteProtectedSkillRoot(containerPath: string): string | null { + const roots = this.getRemoteProtectedSkillRoots().toSorted((a, b) => b.length - a.length); + for (const root of roots) { + if (isPathInsideContainerRoot(root, containerPath)) { + return root; + } + } + return null; + } + + private getRemoteProtectedSkillRoots(): string[] { + const workspaceContainerRoot = normalizeContainerPath(this.runtime.remoteWorkspaceDir); + const agentContainerRoot = normalizeContainerPath(this.runtime.remoteAgentWorkspaceDir); + const roots = [ + path.posix.join(workspaceContainerRoot, "skills"), + path.posix.join(workspaceContainerRoot, ".agents", "skills"), + ]; + if (path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir)) { + roots.push( + path.posix.join(agentContainerRoot, "skills"), + path.posix.join(agentContainerRoot, ".agents", "skills"), + ); + } + return roots; + } + private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { const result = await this.runRemoteScript({ script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', diff --git a/src/agents/sandbox/workspace-skills-bridge-readonly.test.ts b/src/agents/sandbox/workspace-skills-bridge-readonly.test.ts index 71f07841677..6c508617ab9 100644 --- a/src/agents/sandbox/workspace-skills-bridge-readonly.test.ts +++ b/src/agents/sandbox/workspace-skills-bridge-readonly.test.ts @@ -97,6 +97,80 @@ describe("workspace skills bridge mount policy", () => { }, ); + it.runIf(process.platform !== "win32")( + "rejects remote bridge writes under remote-only skill roots", + async () => { + await withTempDir("openclaw-skills-remote-only-", async (stateDir) => { + const workspaceDir = path.join(stateDir, "workspace"); + const remoteWorkspaceDir = path.join(stateDir, "remote-workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(path.join(remoteWorkspaceDir, "skills", "demo"), { recursive: true }); + const canonicalWorkspaceDir = await fs.realpath(workspaceDir); + const canonicalRemoteWorkspaceDir = await fs.realpath(remoteWorkspaceDir); + + const bridge = createRemoteShellSandboxFsBridge({ + sandbox: createSandbox({ + workspaceDir: canonicalWorkspaceDir, + agentWorkspaceDir: canonicalWorkspaceDir, + }), + runtime: { + remoteWorkspaceDir: canonicalRemoteWorkspaceDir, + remoteAgentWorkspaceDir: canonicalRemoteWorkspaceDir, + runRemoteShellScript: async (command) => { + const result = command.script.includes('python3 /dev/fd/3 "$@" 3<<') + ? spawnSync( + "python3", + ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...(command.args ?? [])], + { + input: command.stdin, + encoding: "buffer", + stdio: ["pipe", "pipe", "pipe"], + }, + ) + : spawnSync( + "sh", + ["-c", command.script, "openclaw-test", ...(command.args ?? [])], + { + input: command.stdin, + encoding: "buffer", + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const stdout = Buffer.isBuffer(result.stdout) + ? result.stdout + : Buffer.from(result.stdout ?? []); + const stderr = Buffer.isBuffer(result.stderr) + ? result.stderr + : Buffer.from(result.stderr ?? []); + const code = result.status ?? (result.signal ? 128 : 1); + if (result.error) { + throw result.error; + } + if (code !== 0 && !command.allowFailure) { + throw Object.assign( + new Error(stderr.toString("utf8").trim() || `shell exited with code ${code}`), + { code, stdout, stderr }, + ); + } + return { stdout, stderr, code }; + }, + }, + }); + + await expect( + bridge.writeFile({ + filePath: "skills/demo/SKILL.md", + cwd: canonicalRemoteWorkspaceDir, + data: "# Demo\n", + }), + ).rejects.toThrow(/read-only/); + await expect( + fs.stat(path.join(canonicalRemoteWorkspaceDir, "skills", "demo", "SKILL.md")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + }, + ); + it.runIf(process.platform !== "win32")( "rejects remote bridge mkdirp under skill roots from container cwd", async () => {