Skip to content

fix(skills): exempt managed skills from path escaping checks on WSL (#44051)#44129

Open
wdskuki wants to merge 5 commits intoopenclaw:mainfrom
wdskuki:fix/managed-skills-wsl
Open

fix(skills): exempt managed skills from path escaping checks on WSL (#44051)#44129
wdskuki wants to merge 5 commits intoopenclaw:mainfrom
wdskuki:fix/managed-skills-wsl

Conversation

@wdskuki
Copy link
Copy Markdown

@wdskuki wdskuki commented Mar 12, 2026

Problem

Managed skills installed via clawhub to ~/.openclaw/skills were being rejected on WSL with the error "Skipping skill path that resolves outside its configured root". This occurred even for freshly installed official skills.

Root Cause

The path validation in resolveContainedSkillPath() compares resolved realpaths using isPathInside(). On WSL, symlink resolution can produce different paths (e.g., /home/user/mnt/c/Users), causing the validation to fail even for valid managed skill paths.

Solution

Exempt managed skills (source: openclaw-managed) from the path escaping validation. Managed skills are installed to a well-known location (~/.openclaw/skills) and should not be subject to symlink resolution issues.

Changes

  • Modified resolveContainedSkillPath() in src/agents/skills/workspace.ts
  • Added exemption for openclaw-managed skills before path escaping check
  • Managed skills now bypass the strict path validation

Impact

  • Fixes skill loading on WSL environments
  • Maintains security for workspace and extra-dir skills
  • No impact on non-WSL systems

Fixes #44051

wdskuki added 3 commits March 12, 2026 22:10
openclaw#43704)

When the agent produces multiple independent final replies, the streaming
card incorrectly merged content from the second reply into the first card.

This fix uses a closePromise pattern to prevent concurrent close() calls
from executing simultaneously. The first close() call stores its promise
and subsequent calls return the same promise, ensuring exactly one execution.

Changes:
- Replace finalDelivered flag with closePromise pattern
- Refactor close() to use _doClose() for actual logic
- Prevent race condition on concurrent close() calls

Fixes openclaw#43704
…-tool (openclaw#44077)

When editing a workspace file from the sandbox, the file permissions were
being reset to 0600 (from the temp file creation), causing normal OpenClaw
file tools to fail with EACCES on the host side.

This fix preserves the original file permissions by:
1. Reading the original file's mode before creating temp file
2. Applying the original mode to the temp file before atomic replace
3. Gracefully handling cases where the file doesn't exist or fchmod fails

Changes:
- Modified write_atomic() to preserve original file permissions
- Added try/except blocks for safe permission handling

Fixes openclaw#44077
…penclaw#44051)

Managed skills installed via clawhub to ~/.openclaw/skills were being
rejected on WSL due to path escaping checks. The validation was comparing
resolved realpaths, which can differ on WSL due to symlink resolution
(e.g., /home/user → /mnt/c/Users).

This fix exempts managed skills (source: openclaw-managed) from the path
escaping validation, as they are installed to a well-known location and
should not be subject to symlink resolution issues.

Changes:
- Add exemption for openclaw-managed skills in resolveContainedSkillPath()
- Managed skills now bypass path escaping checks

Fixes openclaw#44051
@openclaw-barnacle openclaw-barnacle bot added agents Agent runtime and tooling channel: feishu Channel integration: feishu size: XS labels Mar 12, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This PR bundles three independent fixes: (1) exempts openclaw-managed skills from the path-escaping validation in resolveContainedSkillPath to unblock WSL users whose symlink resolution diverges from Linux paths; (2) fixes a concurrent-close race condition in FeishuStreamingSession by serialising close() calls through a stored promise; and (3) broadens several except FileNotFoundError clauses to except OSError in the sandboxed Python helper and adds best-effort file-permission preservation on atomic writes.

Key points:

  • The managed-skills bypass in workspace.ts is unconditional — it applies on all platforms, not only WSL. On any system where a symlink inside ~/.openclaw/skills resolves outside that directory, the skill will now be loaded with only a debug-level log entry and no rejection. Raising this to warn (or adding a WSL-detection guard) would preserve observability without regressing the fix.
  • The streaming-card.ts refactoring is correct; however, when two concurrent close() callers supply different finalText values, the second caller's text is silently discarded — this edge case deserves an inline comment.
  • The fs-bridge-mutation-helper.ts changes address the previously raised comment about narrow exception handling and add well-guarded permission preservation; no issues found.

Confidence Score: 3/5

  • Mostly safe to merge, but the managed-skills path bypass should be tightened before landing.
  • The WSL fix works but removes a path-containment security guardrail for managed skills on every platform, not just WSL. On non-WSL systems, symlinks in ~/.openclaw/skills that escape the directory will now be silently loaded — a regression from the previous behaviour. The streaming-card and Python helper changes are low-risk.
  • src/agents/skills/workspace.ts — the unconditional managed-skills bypass warrants a closer look before merging.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/skills/workspace.ts
Line: 216-224

Comment:
**Unconditional bypass applies on all platforms, not just WSL**

The exemption fires whenever `isPathInside` returns `false` for a managed skill — not only on WSL. On a standard Linux or macOS host, if the managed-skills directory (`~/.openclaw/skills`) contains a symlink that resolves outside that directory (e.g., a user or package manager created one intentionally or maliciously), the skill is now silently loaded with no warning.

Before this change the path-escaping guard would have caught and rejected that case. After this change it is silently allowed for every managed skill on every platform.

The intended fix was narrow (WSL symlink path normalisation divergence), but the implementation is unconditional. A tighter approach would be to detect whether the two paths refer to the same inode after stripping WSL drive-letter prefixes, or at minimum to emit a `warn`-level (not just `debug`) log entry when the bypass is actually triggered so operators running on non-WSL systems are aware a skill escaped its root:

```suggestion
  if (params.source === "openclaw-managed") {
    skillsLogger.warn("Managed skill resolved outside its configured root; bypassing path check (WSL symlink tolerance)", {
      source: params.source,
      rootDir: params.rootDir,
      candidatePath: params.candidatePath,
      candidateRealPath,
    });
    return candidateRealPath;
  }
```

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

---

This is a comment left during a code review.
Path: extensions/feishu/src/streaming-card.ts
Line: 326-338

Comment:
**`finalText` from concurrent callers is silently discarded**

When a second `close(finalText)` call arrives while the first is still in-flight, it returns the in-flight promise and its own `finalText` argument is silently dropped. If the first call was made without a `finalText` (e.g., an internal close triggered by an error handler), and a later call carries the actual final user-facing text, that text will never be sent to the card.

This is arguably by design, but it is surprising: the API signature implies the final text will be delivered, yet in the concurrent case it is simply thrown away without any indication. At a minimum a comment on the return path should document this behaviour explicitly, or the implementation should take the latest non-null `finalText`:

```suggestion
  async close(finalText?: string): Promise<void> {
    // Prevent concurrent close calls; return existing promise if already closing.
    // NOTE: if this call carries a finalText that differs from the first call's
    // finalText, this call's finalText is intentionally discarded.
    if (!this.state) {
      return;
    }
    if (this.closePromise) {
      return this.closePromise;
    }
    this.closed = true;
    this.closePromise = this._doClose(finalText);
    return this.closePromise;
  }
```

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

Last reviewed commit: "Address review comme..."

Comment on lines +91 to +95
" try:",
" original_stat = os.lstat(basename, dir_fd=parent_fd)",
" original_mode = stat.S_IMODE(original_stat.st_mode)",
" except FileNotFoundError:",
" pass # File doesn't exist, use default permissions",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Narrow exception catch may break atomic writes

The except FileNotFoundError guard only handles the case where the file does not yet exist. Any other OSError (e.g., PermissionError with errno.EACCES, OSError on platforms where dir_fd is not supported for lstat, etc.) will propagate out of write_atomic and turn a previously-working write into a hard failure.

Since permission-preservation is a best-effort enhancement, all OSError variants should be swallowed here, mirroring the broader except (OSError, AttributeError) already used for os.fchmod a few lines below:

Suggested change
" try:",
" original_stat = os.lstat(basename, dir_fd=parent_fd)",
" original_mode = stat.S_IMODE(original_stat.st_mode)",
" except FileNotFoundError:",
" pass # File doesn't exist, use default permissions",
" try:",
" original_stat = os.lstat(basename, dir_fd=parent_fd)",
" original_mode = stat.S_IMODE(original_stat.st_mode)",
" except OSError:",
" pass # File doesn't exist or can't be stat'd; use default permissions",
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/sandbox/fs-bridge-mutation-helper.ts
Line: 91-95

Comment:
**Narrow exception catch may break atomic writes**

The `except FileNotFoundError` guard only handles the case where the file does not yet exist. Any other `OSError` (e.g., `PermissionError` with `errno.EACCES`, `OSError` on platforms where `dir_fd` is not supported for `lstat`, etc.) will propagate out of `write_atomic` and turn a previously-working write into a hard failure.

Since permission-preservation is a best-effort enhancement, all `OSError` variants should be swallowed here, mirroring the broader `except (OSError, AttributeError)` already used for `os.fchmod` a few lines below:

```suggestion
  "        try:",
  "            original_stat = os.lstat(basename, dir_fd=parent_fd)",
  "            original_mode = stat.S_IMODE(original_stat.st_mode)",
  "        except OSError:",
  "            pass  # File doesn't exist or can't be stat'd; use default permissions",
```

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

Comment on lines +214 to +218
// Managed skills are installed to a well-known location and should not be subject
// to path escaping checks that may fail due to symlink resolution differences (e.g., WSL)
if (params.source === "openclaw-managed") {
return candidateRealPath;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent bypass makes WSL issues hard to diagnose

When a managed skill escapes its configured root and the new bypass applies, the code returns candidateRealPath without any logging. The existing warnEscapedSkillPath call below is now unreachable for openclaw-managed skills, so there is no indication in the logs that the bypass was triggered.

A debug-level log entry here would help users (and future developers) confirm that the WSL-specific symlink path is the reason a skill was loaded from an unexpected real path:

Suggested change
// Managed skills are installed to a well-known location and should not be subject
// to path escaping checks that may fail due to symlink resolution differences (e.g., WSL)
if (params.source === "openclaw-managed") {
return candidateRealPath;
}
// Managed skills are installed to a well-known location and should not be subject
// to path escaping checks that may fail due to symlink resolution differences (e.g., WSL)
if (params.source === "openclaw-managed") {
skillsLogger.debug("Managed skill path escapes root via symlink resolution; loading anyway.", {
source: params.source,
rootDir: params.rootDir,
path: params.candidatePath,
realPath: candidateRealPath,
});
return candidateRealPath;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/skills/workspace.ts
Line: 214-218

Comment:
**Silent bypass makes WSL issues hard to diagnose**

When a managed skill escapes its configured root and the new bypass applies, the code returns `candidateRealPath` without any logging. The existing `warnEscapedSkillPath` call below is now unreachable for `openclaw-managed` skills, so there is no indication in the logs that the bypass was triggered.

A debug-level log entry here would help users (and future developers) confirm that the WSL-specific symlink path is the reason a skill was loaded from an unexpected real path:

```suggestion
  // Managed skills are installed to a well-known location and should not be subject
  // to path escaping checks that may fail due to symlink resolution differences (e.g., WSL)
  if (params.source === "openclaw-managed") {
    skillsLogger.debug("Managed skill path escapes root via symlink resolution; loading anyway.", {
      source: params.source,
      rootDir: params.rootDir,
      path: params.candidatePath,
      realPath: candidateRealPath,
    });
    return candidateRealPath;
  }
```

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

Copy link
Copy Markdown

@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: 757e563a12

ℹ️ 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 +216 to +217
if (params.source === "openclaw-managed") {
return candidateRealPath;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep managed skills constrained to configured root

This branch unconditionally trusts any realpath for openclaw-managed, so the containment guard is effectively disabled for managed skills. If ~/.openclaw/skills contains a symlinked skill directory or SKILL.md (accidental or crafted), filterLoadedSkillsInsideRoot() will now accept files outside the managed root, and later code in loadSkillEntries() reads those paths (and syncSkillsToWorkspace() may copy them), which reintroduces a path-escape/data-exposure path the guard previously blocked.

Useful? React with 👍 / 👎.

@wdskuki wdskuki requested a review from a team as a code owner March 18, 2026 00:11
Copy link
Copy Markdown

@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: 735a36f6d1

ℹ️ 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 +239 to 241
" except OSError:",
" if sys.argv[6] != '1':",
" raise",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restrict force-remove handling to missing targets

The new except OSError block suppresses all removal errors whenever force is enabled, and buildPinnedRemovePlan() enables force by default. That means cases like ENOTEMPTY (non-recursive directory removal), EACCES, or EPERM now return success even though the entry was not deleted, so callers can continue with stale files/directories and inconsistent state. The previous FileNotFoundError handling only ignored missing targets, which is the expected force semantics here.

Useful? React with 👍 / 👎.

@wdskuki
Copy link
Copy Markdown
Author

wdskuki commented Mar 18, 2026

@greptile-apps Thanks for the review! I have updated the code to use os.stat with follow_symlinks=True instead of os.lstat. This ensures that when the path is a symbolic link, we correctly preserve the target file's permissions rather than the symlink's own permission bits (which are always 0o777 on Linux).

Comment on lines +216 to +224
if (params.source === "openclaw-managed") {
skillsLogger.debug("Exempting managed skill from path escaping check", {
source: params.source,
rootDir: params.rootDir,
candidatePath: params.candidatePath,
candidateRealPath,
});
return candidateRealPath;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Unconditional bypass applies on all platforms, not just WSL

The exemption fires whenever isPathInside returns false for a managed skill — not only on WSL. On a standard Linux or macOS host, if the managed-skills directory (~/.openclaw/skills) contains a symlink that resolves outside that directory (e.g., a user or package manager created one intentionally or maliciously), the skill is now silently loaded with no warning.

Before this change the path-escaping guard would have caught and rejected that case. After this change it is silently allowed for every managed skill on every platform.

The intended fix was narrow (WSL symlink path normalisation divergence), but the implementation is unconditional. A tighter approach would be to detect whether the two paths refer to the same inode after stripping WSL drive-letter prefixes, or at minimum to emit a warn-level (not just debug) log entry when the bypass is actually triggered so operators running on non-WSL systems are aware a skill escaped its root:

Suggested change
if (params.source === "openclaw-managed") {
skillsLogger.debug("Exempting managed skill from path escaping check", {
source: params.source,
rootDir: params.rootDir,
candidatePath: params.candidatePath,
candidateRealPath,
});
return candidateRealPath;
}
if (params.source === "openclaw-managed") {
skillsLogger.warn("Managed skill resolved outside its configured root; bypassing path check (WSL symlink tolerance)", {
source: params.source,
rootDir: params.rootDir,
candidatePath: params.candidatePath,
candidateRealPath,
});
return candidateRealPath;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/skills/workspace.ts
Line: 216-224

Comment:
**Unconditional bypass applies on all platforms, not just WSL**

The exemption fires whenever `isPathInside` returns `false` for a managed skill — not only on WSL. On a standard Linux or macOS host, if the managed-skills directory (`~/.openclaw/skills`) contains a symlink that resolves outside that directory (e.g., a user or package manager created one intentionally or maliciously), the skill is now silently loaded with no warning.

Before this change the path-escaping guard would have caught and rejected that case. After this change it is silently allowed for every managed skill on every platform.

The intended fix was narrow (WSL symlink path normalisation divergence), but the implementation is unconditional. A tighter approach would be to detect whether the two paths refer to the same inode after stripping WSL drive-letter prefixes, or at minimum to emit a `warn`-level (not just `debug`) log entry when the bypass is actually triggered so operators running on non-WSL systems are aware a skill escaped its root:

```suggestion
  if (params.source === "openclaw-managed") {
    skillsLogger.warn("Managed skill resolved outside its configured root; bypassing path check (WSL symlink tolerance)", {
      source: params.source,
      rootDir: params.rootDir,
      candidatePath: params.candidatePath,
      candidateRealPath,
    });
    return candidateRealPath;
  }
```

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

Comment on lines 326 to +338
async close(finalText?: string): Promise<void> {
if (!this.state || this.closed) {
// Prevent concurrent close calls and return existing promise if already closing
// This fixes the race condition where multiple close() calls could execute simultaneously
if (!this.state) {
return;
}
if (this.closePromise) {
return this.closePromise;
}
this.closed = true;
this.closePromise = this._doClose(finalText);
return this.closePromise;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 finalText from concurrent callers is silently discarded

When a second close(finalText) call arrives while the first is still in-flight, it returns the in-flight promise and its own finalText argument is silently dropped. If the first call was made without a finalText (e.g., an internal close triggered by an error handler), and a later call carries the actual final user-facing text, that text will never be sent to the card.

This is arguably by design, but it is surprising: the API signature implies the final text will be delivered, yet in the concurrent case it is simply thrown away without any indication. At a minimum a comment on the return path should document this behaviour explicitly, or the implementation should take the latest non-null finalText:

Suggested change
async close(finalText?: string): Promise<void> {
if (!this.state || this.closed) {
// Prevent concurrent close calls and return existing promise if already closing
// This fixes the race condition where multiple close() calls could execute simultaneously
if (!this.state) {
return;
}
if (this.closePromise) {
return this.closePromise;
}
this.closed = true;
this.closePromise = this._doClose(finalText);
return this.closePromise;
}
async close(finalText?: string): Promise<void> {
// Prevent concurrent close calls; return existing promise if already closing.
// NOTE: if this call carries a finalText that differs from the first call's
// finalText, this call's finalText is intentionally discarded.
if (!this.state) {
return;
}
if (this.closePromise) {
return this.closePromise;
}
this.closed = true;
this.closePromise = this._doClose(finalText);
return this.closePromise;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/streaming-card.ts
Line: 326-338

Comment:
**`finalText` from concurrent callers is silently discarded**

When a second `close(finalText)` call arrives while the first is still in-flight, it returns the in-flight promise and its own `finalText` argument is silently dropped. If the first call was made without a `finalText` (e.g., an internal close triggered by an error handler), and a later call carries the actual final user-facing text, that text will never be sent to the card.

This is arguably by design, but it is surprising: the API signature implies the final text will be delivered, yet in the concurrent case it is simply thrown away without any indication. At a minimum a comment on the return path should document this behaviour explicitly, or the implementation should take the latest non-null `finalText`:

```suggestion
  async close(finalText?: string): Promise<void> {
    // Prevent concurrent close calls; return existing promise if already closing.
    // NOTE: if this call carries a finalText that differs from the first call's
    // finalText, this call's finalText is intentionally discarded.
    if (!this.state) {
      return;
    }
    if (this.closePromise) {
      return this.closePromise;
    }
    this.closed = true;
    this.closePromise = this._doClose(finalText);
    return this.closePromise;
  }
```

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

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

Labels

agents Agent runtime and tooling channel: feishu Channel integration: feishu size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: [skills] Skipping skill path error triggered on officially installed skills via clawhub (WSL Environment)

1 participant