Skip to content

feat(tools): add allowedRoots config for fs tools#43565

Open
YJWell wants to merge 10 commits intoopenclaw:mainfrom
YJWell:feat/fs-allowed-roots
Open

feat(tools): add allowedRoots config for fs tools#43565
YJWell wants to merge 10 commits intoopenclaw:mainfrom
YJWell:feat/fs-allowed-roots

Conversation

@YJWell
Copy link

@YJWell YJWell commented Mar 12, 2026

Summary

Implements tools.fs.allowedRoots — an array of additional allowed path prefixes for read/write/edit tools.

Closes #33635

Changes

  • Config schema: added tools.fs.allowedRoots: string[] (default: [])
  • resolveSandboxPath: accepts additionalRoots, returns matchedRoot
  • assertSandboxPath: uses matched root for symlink escape guard
  • Host write/edit tools: wired to respect allowedRoots
  • Tests: integration tests for assertSandboxPath with additionalRoots

Previous attempt

PR #33708 was closed due to bugs found in review. This PR addresses all review findings:

  1. ✅ Tilde expansion via expandPath() (was missing)
  2. ✅ Correct root passed to assertNoPathAliasEscape (was using workspace root for all)
  3. ✅ Integration tests for assertSandboxPath including symlink escape scenarios

Config example

{
  "tools": {
    "fs": {
      "workspaceOnly": true,
      "allowedRoots": ["~/projects", "/shared/data"]
    }
  }
}

@openclaw-barnacle openclaw-barnacle bot added docker Docker and sandbox tooling agents Agent runtime and tooling size: M labels Mar 12, 2026
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This PR implements tools.fs.allowedRoots — an array of additional sandbox-allowed path prefixes that extends the workspace boundary when workspaceOnly: true is set. The core sandboxing changes in sandbox-paths.ts are solid: resolveSandboxPath correctly returns the matchedRoot (with tilde expansion applied to each extra root), and assertSandboxPath passes that matched root to the symlink-escape guard. Integration tests cover the key cases including symlink escape, tilde expansion, and empty-array backward compatibility.

Issues found:

  • mkdir inconsistency in createHostWriteOperations (pi-tools.read.ts ~line 744): The path is pre-resolved with path.resolve(dir) before assertSandboxPath, and that same pre-resolved string is passed to fs.mkdir. assertSandboxPath internally normalises Unicode spaces via expandPath, so the path checked by the sandbox may differ from the path fs.mkdir actually operates on. The writeFile operation in the same function avoids this by using sandboxResult.resolved.

  • access inconsistency in createHostEditOperations (pi-tools.read.ts ~line 828): After assertSandboxPath succeeds, the result is discarded and path.resolve(absolutePath) is called independently. This is inconsistent with readFile and writeFile in the same closure, both of which use sandboxResult.resolved.

  • Redundant .default([]).optional() in Zod schema (zod-schema.agent-runtime.ts line 433): .default([]) prevents the parsed value from ever being undefined, making the chained .optional() a no-op. The two modifiers should not be combined.

Confidence Score: 4/5

  • Safe to merge with minor fixes; no path-traversal bypasses found, but two path-resolution inconsistencies in mkdir/access should be addressed before shipping.
  • Core sandbox logic in sandbox-paths.ts is correct and well-tested. The two inconsistencies in pi-tools.read.ts (mkdir pre-resolving before assertSandboxPath, access discarding sandboxResult) are unlikely to be exploitable in practice since paths reaching these operations should already be absolute, but they create subtle divergence between the validated and operated-on path for Unicode-space inputs. The Zod schema issue is cosmetic. No critical security bypass is present.
  • src/agents/pi-tools.read.tsmkdir and access operations should use sandboxResult.resolved for consistency.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.read.ts
Line: 744-753

Comment:
**`mkdir` should use `sandboxResult.resolved` instead of pre-resolved path**

`path.resolve(dir)` is called before `assertSandboxPath`, and then that same pre-resolved string is passed to `fs.mkdir`. However, `assertSandboxPath` internally applies `expandPath` to the input, which normalises Unicode spaces (e.g. `\u00A0` → space) and expands tilde prefixes. This means the path that passes the sandbox check (`sandboxResult.resolved`) may differ from the `resolved` string that is actually passed to `fs.mkdir`.

The `writeFile` operation in the same function avoids this by capturing and using `sandboxResult.resolved`:
```ts
const sandboxResult = await assertSandboxPath({ ... });
const resolvedPath = sandboxResult.resolved;
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, content, "utf-8");
```

`mkdir` should follow the same pattern:

```suggestion
    mkdir: async (dir: string) => {
      const sandboxResult = await assertSandboxPath({
        filePath: dir,
        cwd: root,
        root,
        additionalRoots: allowedRoots,
      });
      await fs.mkdir(sandboxResult.resolved, { recursive: true });
    },
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/pi-tools.read.ts
Line: 813-837

Comment:
**`access` should reuse `sandboxResult.resolved` for consistency**

After `assertSandboxPath` succeeds, the code discards the returned `sandboxResult` and instead calls `path.resolve(absolutePath)` independently for the actual `fs.access` call. `path.resolve` does not apply `expandPath`'s Unicode-space normalisation, so the path used for the sandbox boundary check and the path used for the filesystem call can differ if `absolutePath` contains Unicode spaces.

Both `readFile` and `writeFile` in `createHostEditOperations` correctly capture and reuse `sandboxResult.resolved`. This function should do the same:

```suggestion
    access: async (absolutePath: string) => {
      let sandboxResult: Awaited<ReturnType<typeof assertSandboxPath>>;
      try {
        sandboxResult = await assertSandboxPath({
          filePath: absolutePath,
          cwd: root,
          root,
          additionalRoots: allowedRoots,
        });
      } catch {
        // Don't throw here – the upstream library replaces any `access` error
        // with a misleading "File not found" message. By returning silently the
        // subsequent `readFile` call will throw through a code-path that
        // propagates the original message.
        return;
      }
      const resolved = sandboxResult.resolved;
      try {
        await fs.access(resolved);
      } catch (error) {
        if ((error as NodeJS.ErrnoException).code === "ENOENT") {
          throw createFsAccessError("ENOENT", absolutePath);
        }
        throw error;
      }
    },
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/config/zod-schema.agent-runtime.ts
Line: 433

Comment:
**Redundant `.default([]).optional()` on `allowedRoots`**

Using `.default([])` and `.optional()` together is contradictory: `.default([])` means Zod will always substitute `[]` when the field is absent, so the value is never `undefined` after parsing, making `.optional()` a no-op at runtime. Pick one:

- If the field should be required with a fallback, keep only `.default([])`.
- If it should be truly optional (callers must handle `undefined`), keep only `.optional()` and handle the missing case at call sites.

Given that `resolveToolFsConfig` already uses `?? globalFs?.allowedRoots` (which handles `undefined`), `.optional()` alone is probably the right choice and is consistent with how `workspaceOnly` is declared on the line above.

```suggestion
    allowedRoots: z.array(z.string()).optional(),
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: f46a20c

Comment on lines 744 to 753
return {
mkdir: async (dir: string) => {
const relative = toRelativeWorkspacePath(root, dir, { allowRoot: true });
const resolved = relative ? path.resolve(root, relative) : path.resolve(root);
await assertSandboxPath({ filePath: resolved, cwd: root, root });
const resolved = path.resolve(dir);
await assertSandboxPath({
filePath: resolved,
cwd: root,
root,
additionalRoots: allowedRoots,
});
await fs.mkdir(resolved, { recursive: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mkdir should use sandboxResult.resolved instead of pre-resolved path

path.resolve(dir) is called before assertSandboxPath, and then that same pre-resolved string is passed to fs.mkdir. However, assertSandboxPath internally applies expandPath to the input, which normalises Unicode spaces (e.g. \u00A0 → space) and expands tilde prefixes. This means the path that passes the sandbox check (sandboxResult.resolved) may differ from the resolved string that is actually passed to fs.mkdir.

The writeFile operation in the same function avoids this by capturing and using sandboxResult.resolved:

const sandboxResult = await assertSandboxPath({ ... });
const resolvedPath = sandboxResult.resolved;
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, content, "utf-8");

mkdir should follow the same pattern:

Suggested change
return {
mkdir: async (dir: string) => {
const relative = toRelativeWorkspacePath(root, dir, { allowRoot: true });
const resolved = relative ? path.resolve(root, relative) : path.resolve(root);
await assertSandboxPath({ filePath: resolved, cwd: root, root });
const resolved = path.resolve(dir);
await assertSandboxPath({
filePath: resolved,
cwd: root,
root,
additionalRoots: allowedRoots,
});
await fs.mkdir(resolved, { recursive: true });
mkdir: async (dir: string) => {
const sandboxResult = await assertSandboxPath({
filePath: dir,
cwd: root,
root,
additionalRoots: allowedRoots,
});
await fs.mkdir(sandboxResult.resolved, { recursive: true });
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.read.ts
Line: 744-753

Comment:
**`mkdir` should use `sandboxResult.resolved` instead of pre-resolved path**

`path.resolve(dir)` is called before `assertSandboxPath`, and then that same pre-resolved string is passed to `fs.mkdir`. However, `assertSandboxPath` internally applies `expandPath` to the input, which normalises Unicode spaces (e.g. `\u00A0` → space) and expands tilde prefixes. This means the path that passes the sandbox check (`sandboxResult.resolved`) may differ from the `resolved` string that is actually passed to `fs.mkdir`.

The `writeFile` operation in the same function avoids this by capturing and using `sandboxResult.resolved`:
```ts
const sandboxResult = await assertSandboxPath({ ... });
const resolvedPath = sandboxResult.resolved;
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, content, "utf-8");
```

`mkdir` should follow the same pattern:

```suggestion
    mkdir: async (dir: string) => {
      const sandboxResult = await assertSandboxPath({
        filePath: dir,
        cwd: root,
        root,
        additionalRoots: allowedRoots,
      });
      await fs.mkdir(sandboxResult.resolved, { recursive: true });
    },
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 813 to 837
access: async (absolutePath: string) => {
let relative: string;
try {
relative = toRelativeWorkspacePath(root, absolutePath);
await assertSandboxPath({
filePath: absolutePath,
cwd: root,
root,
additionalRoots: allowedRoots,
});
} catch {
// Path escapes workspace root. Don't throw here – the upstream
// library replaces any `access` error with a misleading "File not
// found" message. By returning silently the subsequent `readFile`
// call will throw the same "Path escapes workspace root" error
// through a code-path that propagates the original message.
// Don't throw here – the upstream library replaces any `access` error
// with a misleading "File not found" message. By returning silently the
// subsequent `readFile` call will throw through a code-path that
// propagates the original message.
return;
}
const resolved = path.resolve(absolutePath);
try {
const opened = await openFileWithinRoot({
rootDir: root,
relativePath: relative,
});
await opened.handle.close().catch(() => {});
await fs.access(resolved);
} catch (error) {
if (error instanceof SafeOpenError && error.code === "not-found") {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw createFsAccessError("ENOENT", absolutePath);
}
if (error instanceof SafeOpenError && error.code === "outside-workspace") {
// Don't throw here – see the comment above about the upstream
// library swallowing access errors as "File not found".
return;
}
throw error;
}
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

access should reuse sandboxResult.resolved for consistency

After assertSandboxPath succeeds, the code discards the returned sandboxResult and instead calls path.resolve(absolutePath) independently for the actual fs.access call. path.resolve does not apply expandPath's Unicode-space normalisation, so the path used for the sandbox boundary check and the path used for the filesystem call can differ if absolutePath contains Unicode spaces.

Both readFile and writeFile in createHostEditOperations correctly capture and reuse sandboxResult.resolved. This function should do the same:

Suggested change
access: async (absolutePath: string) => {
let relative: string;
try {
relative = toRelativeWorkspacePath(root, absolutePath);
await assertSandboxPath({
filePath: absolutePath,
cwd: root,
root,
additionalRoots: allowedRoots,
});
} catch {
// Path escapes workspace root. Don't throw here – the upstream
// library replaces any `access` error with a misleading "File not
// found" message. By returning silently the subsequent `readFile`
// call will throw the same "Path escapes workspace root" error
// through a code-path that propagates the original message.
// Don't throw here – the upstream library replaces any `access` error
// with a misleading "File not found" message. By returning silently the
// subsequent `readFile` call will throw through a code-path that
// propagates the original message.
return;
}
const resolved = path.resolve(absolutePath);
try {
const opened = await openFileWithinRoot({
rootDir: root,
relativePath: relative,
});
await opened.handle.close().catch(() => {});
await fs.access(resolved);
} catch (error) {
if (error instanceof SafeOpenError && error.code === "not-found") {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw createFsAccessError("ENOENT", absolutePath);
}
if (error instanceof SafeOpenError && error.code === "outside-workspace") {
// Don't throw here – see the comment above about the upstream
// library swallowing access errors as "File not found".
return;
}
throw error;
}
},
access: async (absolutePath: string) => {
let sandboxResult: Awaited<ReturnType<typeof assertSandboxPath>>;
try {
sandboxResult = await assertSandboxPath({
filePath: absolutePath,
cwd: root,
root,
additionalRoots: allowedRoots,
});
} catch {
// Don't throw here – the upstream library replaces any `access` error
// with a misleading "File not found" message. By returning silently the
// subsequent `readFile` call will throw through a code-path that
// propagates the original message.
return;
}
const resolved = sandboxResult.resolved;
try {
await fs.access(resolved);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw createFsAccessError("ENOENT", absolutePath);
}
throw error;
}
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.read.ts
Line: 813-837

Comment:
**`access` should reuse `sandboxResult.resolved` for consistency**

After `assertSandboxPath` succeeds, the code discards the returned `sandboxResult` and instead calls `path.resolve(absolutePath)` independently for the actual `fs.access` call. `path.resolve` does not apply `expandPath`'s Unicode-space normalisation, so the path used for the sandbox boundary check and the path used for the filesystem call can differ if `absolutePath` contains Unicode spaces.

Both `readFile` and `writeFile` in `createHostEditOperations` correctly capture and reuse `sandboxResult.resolved`. This function should do the same:

```suggestion
    access: async (absolutePath: string) => {
      let sandboxResult: Awaited<ReturnType<typeof assertSandboxPath>>;
      try {
        sandboxResult = await assertSandboxPath({
          filePath: absolutePath,
          cwd: root,
          root,
          additionalRoots: allowedRoots,
        });
      } catch {
        // Don't throw here – the upstream library replaces any `access` error
        // with a misleading "File not found" message. By returning silently the
        // subsequent `readFile` call will throw through a code-path that
        // propagates the original message.
        return;
      }
      const resolved = sandboxResult.resolved;
      try {
        await fs.access(resolved);
      } catch (error) {
        if ((error as NodeJS.ErrnoException).code === "ENOENT") {
          throw createFsAccessError("ENOENT", absolutePath);
        }
        throw error;
      }
    },
```

How can I resolve this? If you propose a fix, please make it concise.

const ToolFsSchema = z
.object({
workspaceOnly: z.boolean().optional(),
allowedRoots: z.array(z.string()).default([]).optional(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant .default([]).optional() on allowedRoots

Using .default([]) and .optional() together is contradictory: .default([]) means Zod will always substitute [] when the field is absent, so the value is never undefined after parsing, making .optional() a no-op at runtime. Pick one:

  • If the field should be required with a fallback, keep only .default([]).
  • If it should be truly optional (callers must handle undefined), keep only .optional() and handle the missing case at call sites.

Given that resolveToolFsConfig already uses ?? globalFs?.allowedRoots (which handles undefined), .optional() alone is probably the right choice and is consistent with how workspaceOnly is declared on the line above.

Suggested change
allowedRoots: z.array(z.string()).default([]).optional(),
allowedRoots: z.array(z.string()).optional(),
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/zod-schema.agent-runtime.ts
Line: 433

Comment:
**Redundant `.default([]).optional()` on `allowedRoots`**

Using `.default([])` and `.optional()` together is contradictory: `.default([])` means Zod will always substitute `[]` when the field is absent, so the value is never `undefined` after parsing, making `.optional()` a no-op at runtime. Pick one:

- If the field should be required with a fallback, keep only `.default([])`.
- If it should be truly optional (callers must handle `undefined`), keep only `.optional()` and handle the missing case at call sites.

Given that `resolveToolFsConfig` already uses `?? globalFs?.allowedRoots` (which handles `undefined`), `.optional()` alone is probably the right choice and is consistent with how `workspaceOnly` is declared on the line above.

```suggestion
    allowedRoots: z.array(z.string()).optional(),
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f46a20c78c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +809 to +811
const resolvedPath = sandboxResult.resolved;
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, content, "utf-8");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore pinned writes after sandbox path validation

This workspace-only write path validates with assertSandboxPath and then performs a separate fs.writeFile on the resolved path, which reintroduces a TOCTOU alias race: if another process can mutate the filesystem between the check and write, the final write can be redirected outside the allowed root via symlink/hardlink swaps. The previous writeFileWithinRoot flow used pinned/verified writes to close this gap, so this is a security regression in environments with concurrent/untrusted local processes (including background tool commands).

Useful? React with 👍 / 👎.

@albttx
Copy link

albttx commented Mar 12, 2026

love this !

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 23bc7642f5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

additionalRoots: allowedRoots,
});
return safeRead.buffer;
return await fs.readFile(sandboxResult.resolved);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore pinned reads in workspace-only edit mode

In workspaceOnly mode this now validates with assertSandboxPath and then performs a separate fs.readFile on the path, which reintroduces a TOCTOU alias race: a concurrent local process can swap the validated path to a symlink/hardlink outside the allowed root between check and read, allowing unintended file disclosure. The previous readFileWithinRoot flow pinned and verified the opened file identity specifically to prevent this class of escape.

Useful? React with 👍 / 👎.

const ToolFsSchema = z
.object({
workspaceOnly: z.boolean().optional(),
allowedRoots: z.array(z.string()).default([]),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve global allowedRoots fallback for agent fs config

Defining allowedRoots with .default([]) at schema parse time makes every parsed tools.fs object carry an explicit empty array, so an agent-level tools.fs block that omits allowedRoots will override and drop global tools.fs.allowedRoots instead of inheriting it. That conflicts with the merge logic in resolveToolFsConfig (agentFs?.allowedRoots ?? globalFs?.allowedRoots) and breaks configs that only intended to override workspaceOnly.

Useful? React with 👍 / 👎.

rootDir: root,
relativePath: relative,
rootDir: sandboxResult.matchedRoot,
relativePath: sandboxResult.relative || path.basename(sandboxResult.resolved),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject writes when resolved path is the matched root

When assertSandboxPath returns relative === "" (for example path: "." or path equal to the root), this fallback rewrites the destination to path.basename(resolved) and writes a different file under the root (e.g., /workspace/workspace) instead of rejecting the invalid target. Before this change, root-targeted writes were rejected, so this introduces surprising writes and potential data corruption from ambiguous user input.

Useful? React with 👍 / 👎.

@YJWell YJWell requested a review from a team as a code owner March 14, 2026 23:33
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b557c7bc3c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

relativePath: sandboxResult.relative,
});
return safeRead.buffer;
return safeRead.content.toString("utf-8");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Read edit input from safeRead.buffer

In createHostEditOperations (workspace-only mode), readFileWithinRoot returns a SafeLocalReadResult with a buffer field (src/infra/fs-safe.ts), but this code reads safeRead.content.toString("utf-8"). That makes safeRead.content undefined, so any host edit call with tools.fs.workspaceOnly=true fails at runtime before applying changes (typically Cannot read properties of undefined (reading 'toString')).

Useful? React with 👍 / 👎.

@YJWell YJWell force-pushed the feat/fs-allowed-roots branch 2 times, most recently from 78c5350 to 9c2a04b Compare March 16, 2026 04:00
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c2a04ba13

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 581 to +584
root,
containerWorkdir: options?.containerWorkdir,
});
await assertSandboxPath({ filePath: sandboxPath, cwd: root, root });
await assertSandboxPath({ filePath: sandboxPath, cwd: root, root, additionalRoots });

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map bind-mount paths before applying allowedRoots

When workspaceOnly is enabled in a sandbox run, this guard now forwards additionalRoots directly to assertSandboxPath, but sandboxPath is only remapped for the container workdir (/workspace) and not for other container mounts. In a common bind-mount case (for example tool path /agent/file.txt with allowedRoots containing the host source path), the check compares a container path against host roots and rejects it before the bridge can resolve it, so tools.fs.allowedRoots does not work for sandbox bind mounts unless callers use host paths instead of container paths.

Useful? React with 👍 / 👎.

if (params.additionalRoots?.length) {
for (const extraRoot of params.additionalRoots) {
const expandedExtra = expandPath(extraRoot);
const resolvedExtra = path.resolve(expandedExtra);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-absolute allowedRoots entries

additionalRoots entries are accepted as arbitrary strings and resolved via path.resolve(expandedExtra), which anchors relative values (including "."/"") to the process working directory instead of the sandbox/workspace root. That makes filesystem policy depend on daemon launch cwd and can unintentionally grant access outside the intended workspace when a relative/empty value slips into config.

Useful? React with 👍 / 👎.

@YJWell
Copy link
Author

YJWell commented Mar 16, 2026

Rebased onto latest main (522dda1) to pick up recent fixes. All 5 commits cherry-picked cleanly with no conflicts.

CI status after rebase:

  • ✅ Linux test shards (1/2, 2/2) — pass (these cover all files changed in this PR)
  • ✅ Bun tests — pass
  • ✅ Protocol checks — pass
  • ✅ Install smoke — pass
  • ✅ Lint / actionlint / secrets / docs-scope — pass

Remaining failures are pre-existing on main (verified against run 23126969660 on commit 5432e6e):

  • check — UI tests missing new modelProvider field in GatewaySessionsDefaults
  • startup-memory — exit code 1
  • extensions — Nostr setup-surface import cycle + Feishu plugin mock
  • channels — Telegram clearPluginCommands mock mismatch (flaky)
  • windows shards 3/5/6 — 8.3 short path + above UI/plugin issues

None of these failures touch files modified in this PR (src/agents/pi-tools*, src/agents/sandbox-paths*, src/agents/tool-fs-policy*, src/config/zod-schema.agent-runtime*, src/config/types.tools*).

Ready for review when CI stabilizes on main. Happy to address any feedback.

@YJWell YJWell force-pushed the feat/fs-allowed-roots branch from 9c2a04b to bb4b097 Compare March 16, 2026 06:12
Friday added 5 commits March 18, 2026 20:50
Adds tools.fs.allowedRoots - an array of additional allowed directory
paths for read/write/edit tools when workspaceOnly is true.

This provides fine-grained filesystem access control: agents can access
specific directories outside the workspace without disabling workspaceOnly.

Key changes:
- Config schema: added tools.fs.allowedRoots: string[] (default: [])
- resolveSandboxPath: accepts additionalRoots, returns matchedRoot
- assertSandboxPath: uses matched root for symlink escape guard
- Host write/edit tools: wired to respect allowedRoots
- Tests: integration tests for assertSandboxPath with additionalRoots

Addresses review findings from PR openclaw#33708:
1. Tilde expansion via expandPath() for allowedRoots entries
2. Correct root passed to assertNoPathAliasEscape
3. Integration tests including symlink escape scenarios

Closes openclaw#33635
…tes, drop redundant .optional()

- mkdir: use sandboxResult.resolved instead of pre-resolved path (greptile)
- access: reuse sandboxResult.resolved for consistency (greptile)
- writeFile: use writeFileWithinRoot for TOCTOU-safe pinned writes (codex)
- zod schema: remove redundant .optional() after .default([]) (greptile)
- fix(test): add missing allowedRoots to ToolFsPolicy in pdf/image tests
- fix(security): use pinned readFileWithinRoot in workspace-only edit mode (TOCTOU)
- fix(security): reject read/write when resolved path equals matched root
- fix(config): change allowedRoots to optional() so agent-level config inherits global
@YJWell YJWell force-pushed the feat/fs-allowed-roots branch from bb4b097 to c27cdcf Compare March 18, 2026 12:50
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c27cdcfb54

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 345 to +347
const fsPolicy = createToolFsPolicy({
workspaceOnly: isMemoryFlushRun || fsConfig.workspaceOnly,
allowedRoots: fsConfig.allowedRoots,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor allowedRoots when constructing apply_patch

When tools.fs.workspaceOnly=true and a user adds an extra root (for example /shared) to allowedRoots, read/write/edit now accept that path, but apply_patch still does not: createApplyPatchTool is still created with only { cwd, workspaceOnly }, and src/agents/apply-patch.ts resolves every patch target against options.cwd/assertSandboxPath(... root: options.cwd) with no additional-root override. In practice, a config that allows read /shared/foo.ts will still reject apply_patch on /shared/foo.ts, even though schema.help.ts describes tools.fs as covering apply_patch too.

Useful? React with 👍 / 👎.

Comment on lines 26 to +29
cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.fs : undefined;
return {
workspaceOnly: agentFs?.workspaceOnly ?? globalFs?.workspaceOnly,
allowedRoots: agentFs?.allowedRoots ?? globalFs?.allowedRoots,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Thread allowedRoots through media path consumers too

This new ToolFsPolicy.allowedRoots is only consumed by the coding read/write/edit wrappers. The other places that already honor tools.fs.workspaceOnly still read just the boolean half of the policy—e.g. prompt-image loading in src/agents/pi-embedded-runner/run/attempt.ts:1262/2504-2511, plus createImageTool, createPdfTool, and createImageGenerateTool only pass workspaceOnly into their local-path guards. With a config such as allowedRoots: ["/shared/media"], the read tool can open /shared/media/doc.pdf, but image, pdf, generated-image reference inputs, and auto-detected prompt images will still reject the same file, so the new setting only works for part of the local-file surface.

Useful? React with 👍 / 👎.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d2b893c4d1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +60 to +65
const allowedRoots = options?.allowedRoots ?? [];
if (options?.workspaceOnly) {
return workspaceDir ? [workspaceDir] : [];
return Array.from(new Set([...(workspaceDir ? [workspaceDir] : []), ...allowedRoots]));
}
const roots = getDefaultLocalRoots();
if (!workspaceDir) {
return [...roots];
}
return Array.from(new Set([...roots, workspaceDir]));
return Array.from(new Set([...roots, ...(workspaceDir ? [workspaceDir] : []), ...allowedRoots]));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expand allowedRoots before passing them to media tools

tools.fs.allowedRoots is documented as supporting ~, and assertSandboxPath expands it, but this helper forwards the raw strings into loadWebMedia's localRoots checks. In extensions/whatsapp/src/media.ts:117-123, those roots are only realpath/path.resolved, so an entry like "~/projects" becomes <cwd>/~/projects instead of $HOME/projects. As a result, image, pdf, image_generate, and non-sandbox prompt-image loading still reject files under home-directory allowlists even though read/write/edit/apply_patch accept the same config.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5a466df. resolveMediaToolLocalRoots() now normalizes media allowedRoots with ~ expansion before passing them to loadWebMedia local-root checks, so media tools match the documented filesystem-tool behavior.

Comment on lines +64 to +65
const roots = getDefaultLocalRoots();
if (!workspaceDir) {
return [...roots];
}
return Array.from(new Set([...roots, workspaceDir]));
return Array.from(new Set([...roots, ...(workspaceDir ? [workspaceDir] : []), ...allowedRoots]));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep media allowedRoots gated on workspaceOnly

The schema/help text says tools.fs.allowedRoots only applies "when workspaceOnly is true", but this branch appends those paths even when workspaceOnly is false. That means simply pre-populating allowedRoots now widens image/pdf/image_generate and prompt-image access immediately, which is a behavior change for deployments that leave tools.fs.workspaceOnly off (for example when read is denied but media tools stay enabled).

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5a466df. Media allowedRoots are now only appended when workspaceOnly === true; unrestricted media mode keeps the default roots + workspace and no longer widens access just because allowedRoots is configured.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a466df569

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +289 to +290
await fs.mkdir(path.dirname(sandboxResult.resolved), { recursive: true });
await fs.writeFile(sandboxResult.resolved, content, "utf8");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore pinned writes in workspace-only apply_patch

When apply_patch writes an added/updated/moved file under workspaceOnly, it now validates the path with assertSandboxPath() and then performs separate fs.mkdir()/fs.writeFile() calls. That reintroduces the same TOCTOU alias race the rest of the FS stack avoids: a concurrent local process can swap the validated path or one of its parents to a symlink/hardlink between the check and the write, redirecting the patch outside the allowed root. The previous writeFileWithinRoot flow pinned the target specifically to prevent this class of escape.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2c86b2f. Workspace-only apply_patch writes now go back through writeFileWithinRoot(...) (pinned write helper) instead of fs.mkdir() + fs.writeFile(), and the host-side mkdirp path no longer creates directories eagerly before the pinned write runs.

Comment on lines +67 to +68
const expandedExtra = expandPath(extraRoot);
const resolvedExtra = path.resolve(expandedExtra);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expand ~\ allowedRoots before matching on Windows

The new allowedRoots matching reuses expandPath(), but that helper only expands "~/" and leaves Windows-style home prefixes like "~\\shared" untouched. On Windows hosts, a config such as tools.fs.allowedRoots: ["~\\projects"] therefore resolves against the process CWD instead of the user’s home directory, so paths under %USERPROFILE%\projects are still rejected even though the feature is documented as supporting tilde expansion.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2c86b2f. expandPath() now expands both ~/... and Windows-style ~\\..., so allowedRoots home prefixes resolve consistently on Windows too.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c86b2f220

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +65 to +68
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
return path.join(os.homedir(), trimmed.slice(2));
}
return path.resolve(trimmed);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip @ prefixes from media allowedRoots

Filesystem-tool allowlists already go through expandPath() in src/agents/sandbox-paths.ts, which treats @/foo the same as /foo, but this media-specific normalizer falls through to path.resolve(trimmed). With a config like tools.fs.allowedRoots: ["@/Users/me/media"], read/write/edit/apply_patch accept files under /Users/me/media, while image, pdf, image_generate, and prompt-image loading still reject the same paths because they receive <cwd>/@/Users/... as their local root.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 779b951. Media allowedRoots now strips a leading @ before normalization, so paths like @/Users/me/media resolve the same way as the filesystem-tool path policy instead of becoming <cwd>/@/Users/....

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 779b951513

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 584 to 586
await assertSandboxPath({ filePath: sandboxPath, cwd: root, root, additionalRoots });
}
return tool.execute(toolCallId, normalized ?? args, signal, onUpdate);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize ~\\ paths before calling the wrapped FS tool

On Windows, this wrapper now validates path with assertSandboxPath(), which expands ~\\... and makes configs like tools.fs.allowedRoots: ["~\\projects"] look supported, but it still forwards the original argument unchanged to tool.execute(). The wrapped read/write/edit tools come from pi-coding-agent, whose path resolver only expands ~/, so a call like read {path: "~\\projects\\note.txt"} passes the guard and then resolves under the workspace instead of %USERPROFILE%\\projects. In practice, the new Windows-style home-prefix support only works if callers manually expand the path first.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c71d526. The workspace-root wrapper now normalizes ~, ~/..., ~\\..., and @-prefixed paths before forwarding them to the wrapped read/write/edit tool, so Windows-style home-prefix inputs that pass the guard also resolve correctly inside the underlying FS tool.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c71d526059

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 478 to +479
workspaceOnly: applyPatchWorkspaceOnly,
allowedRoots,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate apply_patch allowedRoots on tools.fs.workspaceOnly

Passing allowedRoots into createApplyPatchTool() here widens apply_patch even when tools.fs.workspaceOnly is still off. The new config help/types (src/config/schema.help.ts:653-654, src/config/types.tools.ts:284-288) say tools.fs.allowedRoots only applies when the FS workspace guard is enabled, but applyPatchWorkspaceOnly is true by default via tools.exec.applyPatch.workspaceOnly, so a config that only sets tools.fs.allowedRoots will suddenly let apply_patch modify those extra directories. That is an undocumented access change for apply_patch-only setups.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling docker Docker and sandbox tooling size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: tools.fs.allowedRoots — whitelist specific directories outside the workspace

2 participants