Skip to content

Commit bf84e3a

Browse files
author
carnie[bot]
committed
fix(security): align audit symlink_escape boundary with skill loader
The skills.workspace.symlink_escape audit probe checked whether skill file realpaths escaped the workspace root (~/.openclaw/workspace/), but the skill loader (resolveContainedSkillPath) checks against the skills directory root (~/.openclaw/workspace/skills/). This mismatch meant symlinks like: workspace/skills/my-skill -> workspace/other-dir/skills/my-skill ...would resolve inside the workspace root (audit says OK) but outside the skills directory (loader silently rejects). The skill would fail to load with zero feedback from `openclaw security audit`. Fix: check against the skills directory realpath in the audit probe, matching the loader's boundary. Add test for the in-workspace but outside-skills-dir case. Relates to #49408
1 parent ee63fdb commit bf84e3a

3 files changed

Lines changed: 36 additions & 5 deletions

File tree

pnpm-lock.yaml

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/security/audit-extra.async.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -881,7 +881,8 @@ export async function collectWorkspaceSkillSymlinkEscapeFindings(params: {
881881

882882
for (const workspaceDir of workspaceDirs) {
883883
const workspacePath = path.resolve(workspaceDir);
884-
const workspaceRealPath = await fs.realpath(workspacePath).catch(() => workspacePath);
884+
const skillsDirPath = path.join(workspacePath, "skills");
885+
const skillsDirRealPath = await fs.realpath(skillsDirPath).catch(() => skillsDirPath);
885886
const skillFilePaths = await listWorkspaceSkillMarkdownFiles(workspacePath);
886887

887888
for (const skillFilePath of skillFilePaths) {
@@ -895,7 +896,7 @@ export async function collectWorkspaceSkillSymlinkEscapeFindings(params: {
895896
if (!skillRealPath) {
896897
continue;
897898
}
898-
if (isPathInside(workspaceRealPath, skillRealPath)) {
899+
if (isPathInside(skillsDirRealPath, skillRealPath)) {
899900
continue;
900901
}
901902
escapedSkillFiles.push({
@@ -913,9 +914,9 @@ export async function collectWorkspaceSkillSymlinkEscapeFindings(params: {
913914
findings.push({
914915
checkId: "skills.workspace.symlink_escape",
915916
severity: "warn",
916-
title: "Workspace skill files resolve outside the workspace root",
917+
title: "Workspace skill files resolve outside the skills directory",
917918
detail:
918-
"Detected workspace `skills/**/SKILL.md` paths whose realpath escapes their workspace root:\n" +
919+
"Detected workspace `skills/**/SKILL.md` paths whose realpath escapes their skills directory:\n" +
919920
escapedSkillFiles
920921
.slice(0, MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS)
921922
.map(

src/security/audit.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1391,7 +1391,35 @@ describe("security audit", () => {
13911391
},
13921392
},
13931393
{
1394-
name: "does not warn for workspace skills that stay inside workspace root",
1394+
name: "warns when workspace skill resolves inside workspace but outside skills dir",
1395+
supported: !isWindows,
1396+
setup: async () => {
1397+
const tmp = await makeTmpDir("workspace-skill-in-root-outside-skills");
1398+
const stateDir = path.join(tmp, "state");
1399+
const workspaceDir = path.join(tmp, "workspace");
1400+
const otherDir = path.join(workspaceDir, "other-dir");
1401+
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
1402+
await fs.mkdir(path.join(workspaceDir, "skills", "leak"), { recursive: true });
1403+
await fs.mkdir(otherDir, { recursive: true });
1404+
1405+
const otherSkillPath = path.join(otherDir, "SKILL.md");
1406+
await fs.writeFile(otherSkillPath, "# inside workspace, outside skills dir\n", "utf-8");
1407+
await fs.symlink(otherSkillPath, path.join(workspaceDir, "skills", "leak", "SKILL.md"));
1408+
1409+
return { stateDir, workspaceDir, otherSkillPath };
1410+
},
1411+
assert: (
1412+
res: SecurityAuditReport,
1413+
fixture: { stateDir: string; workspaceDir: string; otherSkillPath?: string },
1414+
) => {
1415+
const finding = res.findings.find((f) => f.checkId === "skills.workspace.symlink_escape");
1416+
expect(finding?.severity).toBe("warn");
1417+
expect(fixture.otherSkillPath).toBeTruthy();
1418+
expect(finding?.detail).toContain(fixture.otherSkillPath ?? "");
1419+
},
1420+
},
1421+
{
1422+
name: "does not warn for workspace skills that stay inside skills dir",
13951423
supported: true,
13961424
setup: async () => {
13971425
const tmp = await makeTmpDir("workspace-skill-in-root");

0 commit comments

Comments
 (0)