Skip to content

fix: allow trusted exec approvals home symlinks#72377

Merged
vincentkoc merged 1 commit into
mainfrom
clownfish/ghcrawl-191457-agentic-merge
Apr 26, 2026
Merged

fix: allow trusted exec approvals home symlinks#72377
vincentkoc merged 1 commit into
mainfrom
clownfish/ghcrawl-191457-agentic-merge

Conversation

@vincentkoc

Copy link
Copy Markdown
Member

Summary

Validation

  • pnpm -s vitest run src/infra/exec-approvals-store.test.ts
  • pnpm check:changed

Refs: #64663, #68417, #65736, #62917, #64050.

ProjectClownfish replacement details:

Found 0 warnings and 1 error.

@aisle-research-bot

aisle-research-bot Bot commented Apr 26, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Symlink root accepted for OPENCLAW_HOME allows redirecting exec-approvals.json writes to arbitrary location
2 🟡 Medium TOCTOU symlink race in exec approvals directory validation can lead to arbitrary file write
1. 🟡 Symlink root accepted for OPENCLAW_HOME allows redirecting exec-approvals.json writes to arbitrary location
Property Value
Severity Medium
CWE CWE-59
Location src/infra/exec-approvals.ts:230-264

Description

OPENCLAW_HOME (used as the “trusted approvals root”) is expanded into the approvals file path, and ensureDir() attempts to prevent symlink traversal by calling assertNoSymlinkPathComponents(dir, resolveRequiredHomeDir()).

However, after the change, assertNoSymlinkPathComponents() no longer lstat-checks the root itself, allowing the trusted root (OPENCLAW_HOME) to be a symlink. If an attacker can influence OPENCLAW_HOME (or the path it points to) in a higher-privileged execution context, they can redirect creation/overwrite of ~/.openclaw/exec-approvals.json to an unintended directory tree.

Key impact:

  • saveExecApprovals() writes exec-approvals.json via a temporary file + renameSync, so the file write is real and persistent.
  • With a symlinked OPENCLAW_HOME, writes occur under the symlink target (e.g., /etc/.openclaw/exec-approvals.json), bounded only by OS permissions.

Vulnerable logic (root not checked):

let current = resolvedRoot;
for (const segment of segments) {
  current = path.join(current, segment);
  const stat = fs.lstatSync(current);
  if (stat.isSymbolicLink()) {
    throw new Error(`Refusing to traverse symlink in exec approvals path: ${current}`);
  }
}

While rejecting symlinks below the root still helps, accepting a symlinked root removes an important safety property for environments where OPENCLAW_HOME is not fully trusted.

Recommendation

If OPENCLAW_HOME is intended to be a security boundary (“trusted approvals root”), keep allowing symlinked roots only when explicitly opted-in, or harden the file creation to prevent symlink-root redirection.

Options:

  1. Reinstate root lstat check (previous behavior) unless you have a strong reason to allow it:
// Before iterating segments
const rootStat = fs.lstatSync(resolvedRoot);
if (rootStat.isSymbolicLink()) {
  throw new Error(`Refusing symlinked OPENCLAW_HOME approvals root: ${resolvedRoot}`);
}
  1. If you must accept symlinked roots, anchor on the realpath and enforce containment against that:
const resolvedRoot = fs.realpathSync(path.resolve(trustedRoot));
const resolvedTarget = path.resolve(targetPath);// ensure resolvedTarget is within resolvedRoot, then lstat each component
  1. For the final write, prefer an approach that is robust against symlink surprises (platform permitting), e.g. open the directory with O_NOFOLLOW (not directly exposed in Node for all cases) or validate fs.realpathSync(dir) starts with the expected real root.

Also document clearly that OPENCLAW_HOME must be treated as trusted input and should not be attacker-controlled in privileged contexts.

2. 🟡 TOCTOU symlink race in exec approvals directory validation can lead to arbitrary file write
Property Value
Severity Medium
CWE CWE-367
Location src/infra/exec-approvals.ts:230-264

Description

The exec approvals writer attempts to prevent symlink traversal by lstatSync-checking each path component before creating/writing the approvals file. However, the code then performs filesystem operations by pathname (mkdirSync, writeFileSync, renameSync) without using race-resistant primitives (e.g., openat/O_NOFOLLOW-style semantics per path component).

With the change, assertNoSymlinkPathComponents no longer checks the trusted root itself (allowing a symlinked OPENCLAW_HOME). This increases reliance on the subsequent non-atomic path usage.

Impact:

  • A local attacker who can modify the filesystem under OPENCLAW_HOME (or swap the symlink target of OPENCLAW_HOME when it is a symlink) could replace a previously-checked directory component with a symlink between the check and the use.
  • The subsequent writeFileSync(tempPath, ...) and renameSync(tempPath, filePath) can then be redirected to write attacker-chosen locations, potentially overwriting sensitive files if the process has higher privileges.

Vulnerable sequence:

assertNoSymlinkPathComponents(dir, resolveRequiredHomeDir());
fs.mkdirSync(dir, { recursive: true });
...
fs.writeFileSync(tempPath, raw, { mode: 0o600, flag: "wx" });
fs.renameSync(tempPath, filePath);

Recommendation

Avoid TOCTOU symlink races by performing operations relative to a trusted directory handle and refusing symlinks at use time.

In Node.js, consider:

  • Resolving the real home directory with fs.realpathSync.native() and using that as the trusted root (if you must allow symlinked OPENCLAW_HOME, resolve once and then operate under the real path).
  • Creating/opening the approvals directory first, then validating it via realpath/lstat, and writing the file via a file descriptor when possible.
  • On platforms that support it, use fs.openSync with flags to reduce symlink following, and avoid mkdirSync({recursive:true}) on untrusted paths.

Example hardening (partial):

const rootReal = fs.realpathSync.native(resolveRequiredHomeDir());
const approvalsDir = path.join(rootReal, ".openclaw");
fs.mkdirSync(approvalsDir, { mode: 0o700 });
const st = fs.lstatSync(approvalsDir);
if (!st.isDirectory() || st.isSymbolicLink()) throw new Error("unsafe");

const tmpFd = fs.openSync(path.join(approvalsDir, `...tmp`), "wx", 0o600);
try {
  fs.writeFileSync(tmpFd, raw);
} finally {
  fs.closeSync(tmpFd);
}

If the threat model includes execution with elevated privileges, additionally ignore/clear OPENCLAW_HOME when running as root/admin or when environment is not trusted.


Analyzed PR: #72377 at commit b4c2511

Last updated on: 2026-04-26T21:54:27Z

@openclaw-barnacle openclaw-barnacle Bot added size: XS maintainer Maintainer-authored PR labels Apr 26, 2026
@greptile-apps

greptile-apps Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a regression in assertNoSymlinkPathComponents where checking the trusted root path (OPENCLAW_HOME) itself with lstatSync caused symlinked home directories to be incorrectly rejected. The fix removes the initial "." sentinel from the loop, so only path segments below the resolved root are checked — the root itself is now treated as implicitly trusted. The multi-layer guards in ensureDir (lstatSync + isSymbolicLink check on the final directory) and assertSafeExecApprovalsDestination (file-level symlink check) remain intact. Two focused regression tests cover the accepted and rejected cases correctly.

Confidence Score: 5/5

Safe to merge — minimal targeted fix with correct behaviour verified by new tests.

The logic change is small and well-reasoned: removing the sentinel '.' element skips only the root check, leaving all sub-path symlink checks intact. The existing ensureDir and assertSafeExecApprovalsDestination guards provide defence-in-depth. Tests cover both the acceptance and rejection paths. No security issues introduced.

No files require special attention.

Reviews (1): Last reviewed commit: "fix: allow trusted exec approvals home s..." | Re-trigger Greptile

@FunJim

FunJim commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Thanks again for landing this. I dug into the merged behavior on current main, and there’s still one common symlinked-home setup that remains broken.

#72377 fixed the case where OPENCLAW_HOME itself is a symlinked trusted root, but it still rejects the default dotfiles/Stow layout where:

  • trusted root = /Users/<user>
  • approvals dir = /Users/<user>/.openclaw
  • ~/.openclaw is a symlink to a git-managed config dir

In that setup, ensureDir() still calls:

assertNoSymlinkPathComponents(dir, resolveRequiredHomeDir());

and assertNoSymlinkPathComponents() still rejects the first symlinked component below the resolved home root:

const stat = fs.lstatSync(current);
if (stat.isSymbolicLink()) {
  throw new Error(`Refusing to traverse symlink in exec approvals path: ${current}`);
}

So with:

~/.openclaw -> ~/workspace/openclaw-config/openclaw/.openclaw

openclaw exec-policy preset yolo still fails with:

Refusing to traverse symlink in exec approvals path: /Users/funjim/.openclaw

I filed the remaining regression separately here so it doesn’t get lost:

In short: #72377 fixed the symlinked OPENCLAW_HOME case, but not the symlinked ~/.openclaw case that #64663 was originally trying to cover more broadly.

bminicore pushed a commit to bminicore/openclaw-fork that referenced this pull request Apr 27, 2026
Bojun-Vvibe added a commit to Bojun-Vvibe/openclaw that referenced this pull request Apr 27, 2026
The exec-approvals symlink hardening from openclaw#72377 still rejected a
symlinked ~/.openclaw immediate child of the trusted home directory,
breaking exec-policy commands for users who manage OpenClaw config via
GNU Stow, chezmoi, or other dotfile managers. openclaw#72377 only relaxed the
restriction on the OPENCLAW_HOME root itself.

Allow exactly one trusted symlink hop at the immediate child of the
trusted home root, but only when the realpath target is owned by the
current effective user AND is not group/other writable. Deeper symlinks
inside the resolved .openclaw tree, additional symlink hops, and
unsafe-permission targets remain rejected with the original error
message.

Updates the existing 'refuses to traverse symlinked approvals
components below a symlinked home' test to construct an unsafe target
(group-writable) so the deeper-symlink rejection intent of openclaw#72377 is
still exercised, and adds a new test for the safe-symlink case from
this issue.

Fixes openclaw#72572
Refines openclaw#72377, openclaw#64663, openclaw#64050
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer Maintainer-authored PR size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants