fix(security): align audit symlink_escape boundary with skill loader#60155
fix(security): align audit symlink_escape boundary with skill loader#60155menhguin wants to merge 1 commit into
Conversation
The audit probe checked against the workspace root, but the skill loader (resolveContainedSkillPath) checks against the skills directory root. Symlinks resolving inside the workspace but outside workspace/skills/ passed audit but were silently rejected at load time. Align the audit boundary with the loader so these cases are flagged.
Greptile SummaryThis PR tightens the Confidence Score: 5/5Safe to merge — the boundary fix is correct and well-tested; only a minor test comment mismatch remains. All findings are P2 (a misleading comment in the new test). The core logic change correctly aligns the audit boundary with the loader, and the new test validates the previously-missed gap scenario. No files require special attention. Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/security/audit.test.ts
Line: 1397-1401
Comment:
**Test comment describes relative symlink, code creates absolute**
The comment says `../../shared/skills/my-skill/SKILL.md` (relative), but `fs.symlink` receives an absolute path via `path.join(sharedSkillDir, "SKILL.md")`. Both types correctly exercise the escape scenario here, but the comment mismatches the actual symlink type and could mislead someone copying this test pattern.
```suggestion
// Symlink: skills/linked-skill/SKILL.md -> <abs>/workspace/shared/skills/my-skill/SKILL.md
await fs.symlink(
path.join(sharedSkillDir, "SKILL.md"),
path.join(workspaceDir, "skills", "linked-skill", "SKILL.md"),
);
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(security): align audit symlink_escap..." | Re-trigger Greptile |
| // Symlink: skills/linked-skill/SKILL.md -> ../../shared/skills/my-skill/SKILL.md | ||
| await fs.symlink( | ||
| path.join(sharedSkillDir, "SKILL.md"), | ||
| path.join(workspaceDir, "skills", "linked-skill", "SKILL.md"), | ||
| ); |
There was a problem hiding this comment.
Test comment describes relative symlink, code creates absolute
The comment says ../../shared/skills/my-skill/SKILL.md (relative), but fs.symlink receives an absolute path via path.join(sharedSkillDir, "SKILL.md"). Both types correctly exercise the escape scenario here, but the comment mismatches the actual symlink type and could mislead someone copying this test pattern.
| // Symlink: skills/linked-skill/SKILL.md -> ../../shared/skills/my-skill/SKILL.md | |
| await fs.symlink( | |
| path.join(sharedSkillDir, "SKILL.md"), | |
| path.join(workspaceDir, "skills", "linked-skill", "SKILL.md"), | |
| ); | |
| // Symlink: skills/linked-skill/SKILL.md -> <abs>/workspace/shared/skills/my-skill/SKILL.md | |
| await fs.symlink( | |
| path.join(sharedSkillDir, "SKILL.md"), | |
| path.join(workspaceDir, "skills", "linked-skill", "SKILL.md"), | |
| ); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/security/audit.test.ts
Line: 1397-1401
Comment:
**Test comment describes relative symlink, code creates absolute**
The comment says `../../shared/skills/my-skill/SKILL.md` (relative), but `fs.symlink` receives an absolute path via `path.join(sharedSkillDir, "SKILL.md")`. Both types correctly exercise the escape scenario here, but the comment mismatches the actual symlink type and could mislead someone copying this test pattern.
```suggestion
// Symlink: skills/linked-skill/SKILL.md -> <abs>/workspace/shared/skills/my-skill/SKILL.md
await fs.symlink(
path.join(sharedSkillDir, "SKILL.md"),
path.join(workspaceDir, "skills", "linked-skill", "SKILL.md"),
);
```
How can I resolve this? If you propose a fix, please make it concise.|
Closing this as duplicate or superseded after Codex automated review. Close #60155 as superseded by the later open PR #60513, which tracks the same remaining security-audit boundary fix. Current main has not implemented the fix yet, so this is not an implemented-on-main close. Best possible solution: Close #60155 as duplicate/superseded by open #60513, then use #60513 or a maintainer branch to port the boundary fix onto the current audit-workspace-skills.ts implementation, update the dedicated audit-workspace-skill-escape test, and align the audit-check docs/finding copy with the skills-directory boundary. What I checked:
So I’m closing this here and keeping the remaining discussion on the canonical linked item. Codex Review notes: model gpt-5.5, reasoning high; reviewed against e29d3516bf05. |
Problem
The
skills.workspace.symlink_escapeaudit probe checks whether workspace skill realpaths escape the workspace root, but the skill loader (resolveContainedSkillPath) checks whether they escape the skills directory root (workspace/skills/). This creates a gap where symlinks that resolve inside the workspace but outside the skills directory pass the audit but are silently rejected at load time.Example:
workspace/skills/my-skill -> ../shared/skills/my-skillresolves toworkspace/shared/skills/my-skill— inside the workspace root (audit passes ✅) but outsideworkspace/skills/(loader rejects ❌).Impact
We had 10 symlinked skills silently invisible for nearly a month. The daily-review cron skill stopped working reliably when v2026.3.7 shipped the loader enforcement — but
openclaw security auditnever flagged it. The model compensated by discovering the skill file through manual filesystem search ~80% of the time, masking the issue.Fix
Align the audit probe boundary with the loader: check against the skills directory root, not the workspace root. One-line logic change + updated finding messages + test for the gap case.
Changes
audit-extra.async.ts: resolveskillsDir(workspace/skills) instead ofworkspacePath, use it as the containment boundaryaudit.test.ts: add test case for symlink inside workspace but outside skills dirskills.load.extraDirsmention in remediation textCI Note
Windows test failures in
task-owner-access.test.tsare pre-existing timeouts, unrelated to this change.Closes #60145