Skip to content

fix(exec-policy): accept trusted ~/.openclaw symlink at home boundary#72650

Closed
Bojun-Vvibe wants to merge 3 commits into
openclaw:mainfrom
Bojun-Vvibe:fix/exec-policy-symlink-openclaw-72572
Closed

fix(exec-policy): accept trusted ~/.openclaw symlink at home boundary#72650
Bojun-Vvibe wants to merge 3 commits into
openclaw:mainfrom
Bojun-Vvibe:fix/exec-policy-symlink-openclaw-72572

Conversation

@Bojun-Vvibe

Copy link
Copy Markdown

Fixes #72572.

Problem

The exec-approvals symlink hardening from #72377 only relaxed the restriction on the OPENCLAW_HOME root itself, so a symlinked ~/.openclaw immediate child of a real home directory still trips assertNoSymlinkPathComponents():

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

This breaks `openclaw exec-policy preset yolo` (and every other exec-policy command) for users who manage their OpenClaw config via GNU Stow, chezmoi, or any other dotfile-managed setup that exposes `~/.openclaw → ~/dotfiles/openclaw/.openclaw`.

Fix

Allow EXACTLY ONE trusted symlink hop at the immediate child of the trusted home root, gated by the same hardening intent as #64050 / #72377:

  1. Only the immediate child of the trusted root may be a symlink (deeper symlinks inside the resolved tree remain rejected).
  2. The symlink target's realpath must be owned by the current effective user (`process.geteuid()`).
  3. The symlink target must not be group- or other-writable (mode bits `0o022`).
  4. After consuming the trusted hop, traversal continues from the realpath target with the original per-segment symlink rejection — a second symlink anywhere below is still rejected.

Permissive on platforms without `process.geteuid` (Windows), where the OS ACL model already differs.

`ensureDir()` is taught to accept a `dir` whose `lstatSync` reports a symlink, but only after re-asserting the realpath target is a directory — `assertNoSymlinkPathComponents()` already vetted ownership/perms.

Tests

  • New: `accepts a symlinked ~/.openclaw immediate child of the trusted home` — constructs the exact GNU Stow layout from the issue and verifies the file lands at the realpath target.
  • Updated: `refuses to traverse symlinked approvals components below a symlinked home` — now constructs an unsafe (group-writable) symlink target so the deeper-symlink rejection intent of fix: allow trusted exec approvals home symlinks #72377 is still exercised. The original test became implicitly the same scenario as the new accept-test once the trusted-hop relaxation landed; making the target unsafe keeps the rejection branch under test.

All 18 `exec-approvals-store` tests pass; `pnpm tsgo:test` clean.

Diff

+92 / -3 across 2 production files + 1 test file + CHANGELOG.

Refs

@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR allows a symlinked ~/.openclaw at the immediate child of the trusted home root (the GNU Stow / chezmoi dotfile layout) by adding a single trusted-hop relaxation in assertNoSymlinkPathComponents and updating ensureDir to accept a symlinked directory when its realpath target is a real directory. The ownership/mode guards (euid match, no group/other-write bits) are a reasonable safety bound for the new hop.

Confidence Score: 4/5

Safe to merge; both findings are P2 and the core security logic is sound.

The trusted-hop check correctly enforces ownership and mode before following a symlink, and deeper symlinks remain rejected. Two P2 concerns: (1) the trustedSymlinkConsumed flag appears in a guard condition where it can never fire, making it misleading dead code; (2) realpathSync inside the existing ENOENT-swallowing try block means a dangling symlink silently bypasses the ownership/mode assertion and falls through to a confusing EEXIST from mkdirSync. Neither is a security bypass today, but the second degrades the diagnostic experience and could become a subtle safety gap if the try/catch scope changes in the future.

src/infra/exec-approvals.ts — the assertNoSymlinkPathComponents try/catch scope (lines 281–290)

Comments Outside Diff (1)

  1. src/infra/exec-approvals.ts, line 281-290 (link)

    P2 realpathSync ENOENT is swallowed, silently skipping the ownership/mode check

    The catch block was designed to tolerate a non-existent path segment (lstatSync → ENOENT). But now fs.realpathSync(current) also lives inside the same try, and it throws ENOENT when ~/.openclaw is a dangling symlink (symlink exists, target directory does not — the state right after stow or chezmoi creates the link but before materialising the target). In that case: lstatSync succeeds and returns isSymbolicLink() === true, then realpathSync throws ENOENT which is caught and swallowed, the ownership/mode assertion is never reached, and mkdirSync(dir) hits the dangling symlink and throws EEXIST — a cryptic failure rather than the clear "Refusing to traverse symlink" message. The fix is to limit the ENOENT swallow to lstatSync by re-throwing any error that originates from inside the symlink-handling branch.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/infra/exec-approvals.ts
    Line: 281-290
    
    Comment:
    **`realpathSync` ENOENT is swallowed, silently skipping the ownership/mode check**
    
    The `catch` block was designed to tolerate a non-existent path segment (`lstatSync` → ENOENT). But now `fs.realpathSync(current)` also lives inside the same `try`, and it throws ENOENT when `~/.openclaw` is a dangling symlink (symlink exists, target directory does not — the state right after `stow` or `chezmoi` creates the link but before materialising the target). In that case: `lstatSync` succeeds and returns `isSymbolicLink() === true`, then `realpathSync` throws ENOENT which is caught and swallowed, the ownership/mode assertion is never reached, and `mkdirSync(dir)` hits the dangling symlink and throws `EEXIST` — a cryptic failure rather than the clear "Refusing to traverse symlink" message. The fix is to limit the ENOENT swallow to `lstatSync` by re-throwing any error that originates from inside the symlink-handling branch.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/infra/exec-approvals.ts
Line: 278-279

Comment:
**`trustedSymlinkConsumed` guard is logically dead code**

The condition `!isImmediateRootChild || trustedSymlinkConsumed` is equivalent to plain `!isImmediateRootChild`. The flag is only ever set to `true` at `i === 0`, and the loop never revisits `i === 0`, so when `trustedSymlinkConsumed` could be `true`, `i` is already `≥ 1` — making `!isImmediateRootChild` true first. The flag adds the appearance of a "second-hop guard" without actually providing it; a reader (or future diff) might assume it defends against a case it cannot reach.

```suggestion
        if (!isImmediateRootChild) {
          throw new Error(`Refusing to traverse symlink in exec approvals path: ${current}`);
```

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/infra/exec-approvals.ts
Line: 281-290

Comment:
**`realpathSync` ENOENT is swallowed, silently skipping the ownership/mode check**

The `catch` block was designed to tolerate a non-existent path segment (`lstatSync` → ENOENT). But now `fs.realpathSync(current)` also lives inside the same `try`, and it throws ENOENT when `~/.openclaw` is a dangling symlink (symlink exists, target directory does not — the state right after `stow` or `chezmoi` creates the link but before materialising the target). In that case: `lstatSync` succeeds and returns `isSymbolicLink() === true`, then `realpathSync` throws ENOENT which is caught and swallowed, the ownership/mode assertion is never reached, and `mkdirSync(dir)` hits the dangling symlink and throws `EEXIST` — a cryptic failure rather than the clear "Refusing to traverse symlink" message. The fix is to limit the ENOENT swallow to `lstatSync` by re-throwing any error that originates from inside the symlink-handling branch.

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

Reviews (1): Last reviewed commit: "fix(exec-policy): accept trusted ~/.open..." | Re-trigger Greptile

Comment thread src/infra/exec-approvals.ts Outdated
@Bojun-Vvibe
Bojun-Vvibe force-pushed the fix/exec-policy-symlink-openclaw-72572 branch 3 times, most recently from f8eb14f to caced25 Compare April 27, 2026 07:44
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
@Bojun-Vvibe
Bojun-Vvibe force-pushed the fix/exec-policy-symlink-openclaw-72572 branch from 254e1ea to 3bb8640 Compare April 27, 2026 07:58
Greptile review on openclaw#72572: the flag was logically dead — it can only be set
when i === 0, and the loop never revisits i === 0, so by the time it could
be true, !isImmediateRootChild is already true. Removing it makes the
at-most-one-hop invariant rest visibly on the !isImmediateRootChild guard.
Behavior is unchanged; existing tests still cover the single-hop allow,
double-hop reject, and untrusted-target reject paths.
@clawsweeper

clawsweeper Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Close as superseded: the underlying symlinked .openclaw exec approvals bug remains valid, but this stale PR is conflicting, targets the pre-@openclaw/fs-safe helper shape, and is already tracked by the canonical open issue.

Root-cause cluster
Relationship: fixed_by_candidate
Canonical: #72572
Summary: This PR is a stale candidate fix for the canonical symlinked .openclaw exec approvals issue; the issue remains open while candidate PRs are conflicting and proof-missing.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Canonical path: Keep #72572 open and land one rebased, security-reviewed fix against the current assertNoExecApprovalsSymlinkParents() / @openclaw/fs-safe path.

So I’m closing this here and keeping the remaining discussion on #72572.

Review details

Best possible solution:

Keep #72572 open and land one rebased, security-reviewed fix against the current assertNoExecApprovalsSymlinkParents() / @openclaw/fs-safe path.

Do we have a high-confidence way to reproduce the issue?

Yes. Source inspection shows exec-policy preset reaches saveExecApprovals(), and current main still rejects a symlinked .openclaw approvals directory before writing.

Is this the best way to solve the issue?

No. The compatibility direction is valid, but this branch is not the best current fix because it targets the removed local path walker, lacks real behavior proof, and needs a security-reviewed current-main implementation.

Security review:

Security review needs attention: The patch changes command-approval filesystem trust boundaries and is stale against the current fs-safe/chmod implementation.

  • [medium] Preserve current approvals-directory hardening — src/infra/exec-approvals.ts:232
    The PR head changes the older local path walker, while current main uses the shared fs-safe guard and applies owner-only permissions after creating the approvals directory; a current-main rebuild must preserve those protections.
    Confidence: 0.86
  • [low] Do not swallow symlink realpath failures — src/infra/exec-approvals.ts:282
    The proposed symlink branch resolves realpathSync(current) inside an ENOENT-swallowing catch, so a dangling symlink can skip target validation and fall through to a less clear failure path.
    Confidence: 0.78

AGENTS.md: found and applied where relevant.

What I checked:

  • Root policy read: Root AGENTS.md was read fully and applied; it requires current-main comparison, whole-surface PR review, security-boundary caution, and real behavior proof for this PR. (AGENTS.md:1, 010b61746379)
  • Current main still rejects symlinked approvals directory: Current ensureDir() validates the approvals directory, creates it, then uses fs.lstatSync(dir) and rejects symbolic-link directories. (src/infra/exec-approvals.ts:417, 010b61746379)
  • Current wrapper uses fs-safe without the root-child exception: assertNoExecApprovalsSymlinkParents() delegates to assertNoSymlinkParentsSync with allowOutsideRoot and a message prefix, but does not pass allowRootChildSymlink. (src/infra/exec-approvals.ts:435, 010b61746379)
  • Current tests encode the unresolved gap: The store tests accept a symlinked OPENCLAW_HOME root but still expect a .openclaw symlink below that boundary to throw. (src/infra/exec-approvals-store.test.ts:646, 010b61746379)
  • PR head targets old helper shape: The PR head adds the allowance to assertNoSymlinkPathComponents(), a helper that current main replaced with assertNoExecApprovalsSymlinkParents() backed by @openclaw/fs-safe. (src/infra/exec-approvals.ts:232, f02e100f4369)
  • fs-safe refactor provenance: Commit 538605ff44d23e4e57ecfd72a8f0b20159ffebbf extracted filesystem safety primitives and replaced the local exec approvals path walker with the shared fs-safe wrapper. (src/infra/exec-approvals.ts:435, 538605ff44d2)

Likely related people:

  • Takhoffman: Commit 4bf94aa0d669 added the local exec-policy CLI and hardened the approvals write path used by the reported command. (role: introduced affected CLI surface; confidence: high; commits: 4bf94aa0d669; files: src/cli/exec-policy-cli.ts, src/infra/exec-approvals.ts, src/infra/exec-approvals-store.test.ts)
  • vincentkoc: Commit 364d49889e67 repaired trusted OPENCLAW_HOME symlinks in the same approvals path while leaving the .openclaw child-symlink case rejected. (role: recent symlink-fix contributor; confidence: high; commits: 364d49889e67; files: src/infra/exec-approvals.ts, src/infra/exec-approvals-store.test.ts)
  • steipete: Commit 538605ff44d2 extracted the fs-safe primitives now used by current main's exec approvals symlink guard. (role: adjacent filesystem-safety contributor; confidence: high; commits: 538605ff44d2; files: src/infra/fs-safe-advanced.ts, src/infra/exec-approvals.ts)
  • jesse-merhi: Commit c9707ab635b9 recently changed command authorization and exec approvals store/tests adjacent to this path. (role: recent adjacent exec approvals contributor; confidence: medium; commits: c9707ab635b9; files: src/infra/exec-approvals.ts, src/infra/exec-approvals-store.test.ts, src/gateway/server-methods/exec-approval.ts)

Codex review notes: model internal, reasoning high; reviewed against 010b61746379.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels May 20, 2026
@clawsweeper

clawsweeper Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper PR egg

🎁 Pass real behavior proof to wake the egg and unlock a hatchable treat.

Where did the egg go?
  • The egg game starts only after the PR passes the real-behavior proof check.
  • Before that, no creature or rarity is rolled. The treat waits for real proof.
  • This is still just collectible flavor: proof affects review readiness, not creature quality.

@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Jun 4, 2026
@clawsweeper clawsweeper Bot added rating: 🌊 off-meta tidepool PR readiness rating does not apply to this item. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jun 4, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the stale Marked as stale due to inactivity label Jun 14, 2026
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. and removed rating: 🌊 off-meta tidepool PR readiness rating does not apply to this item. labels Jun 14, 2026
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jun 17, 2026
@steipete steipete closed this Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. P2 Normal backlog priority with limited blast radius. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: S status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: exec-policy still rejects symlinked ~/.openclaw after #72377 replacement for #64663

2 participants