fix(skills): exempt managed skills from path escaping checks on WSL (#44051)#44129
fix(skills): exempt managed skills from path escaping checks on WSL (#44051)#44129wdskuki wants to merge 5 commits intoopenclaw:mainfrom
Conversation
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
Greptile SummaryThis PR bundles three independent fixes: (1) exempts Key points:
Confidence Score: 3/5
Prompt To Fix All With AIThis 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..." |
| " 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", |
There was a problem hiding this 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:
| " 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.| // 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; | ||
| } |
There was a problem hiding this 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:
| // 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.There was a problem hiding this comment.
💡 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".
| if (params.source === "openclaw-managed") { | ||
| return candidateRealPath; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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".
| " except OSError:", | ||
| " if sys.argv[6] != '1':", | ||
| " raise", |
There was a problem hiding this comment.
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 👍 / 👎.
|
@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). |
| 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; | ||
| } |
There was a problem hiding this 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:
| 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.| 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; | ||
| } |
There was a problem hiding this 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:
| 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.
Problem
Managed skills installed via
clawhubto~/.openclaw/skillswere 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 usingisPathInside(). 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
resolveContainedSkillPath()insrc/agents/skills/workspace.tsopenclaw-managedskills before path escaping checkImpact
Fixes #44051