Skip to content

Commit 0daf503

Browse files
Minh NguyenMinh Nguyen
authored andcommitted
fix(security): align audit symlink_escape boundary with skill loader
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.
1 parent dc45faa commit 0daf503

2 files changed

Lines changed: 37 additions & 6 deletions

File tree

src/security/audit-extra.async.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -847,7 +847,9 @@ export async function collectWorkspaceSkillSymlinkEscapeFindings(params: {
847847

848848
for (const workspaceDir of workspaceDirs) {
849849
const workspacePath = path.resolve(workspaceDir);
850-
const workspaceRealPath = await fs.realpath(workspacePath).catch(() => workspacePath);
850+
// Match the skill loader boundary (resolveContainedSkillPath): skills dir root, not workspace root.
851+
const skillsDir = path.join(workspacePath, "skills");
852+
const skillsDirRealPath = await fs.realpath(skillsDir).catch(() => skillsDir);
851853
const skillFilePaths = await listWorkspaceSkillMarkdownFiles(workspacePath);
852854

853855
for (const skillFilePath of skillFilePaths) {
@@ -861,7 +863,7 @@ export async function collectWorkspaceSkillSymlinkEscapeFindings(params: {
861863
if (!skillRealPath) {
862864
continue;
863865
}
864-
if (isPathInside(workspaceRealPath, skillRealPath)) {
866+
if (isPathInside(skillsDirRealPath, skillRealPath)) {
865867
continue;
866868
}
867869
escapedSkillFiles.push({
@@ -879,9 +881,9 @@ export async function collectWorkspaceSkillSymlinkEscapeFindings(params: {
879881
findings.push({
880882
checkId: "skills.workspace.symlink_escape",
881883
severity: "warn",
882-
title: "Workspace skill files resolve outside the workspace root",
884+
title: "Workspace skill files resolve outside the skills directory",
883885
detail:
884-
"Detected workspace `skills/**/SKILL.md` paths whose realpath escapes their workspace root:\n" +
886+
"Detected workspace `skills/**/SKILL.md` paths whose realpath escapes the skills directory root:\n" +
885887
escapedSkillFiles
886888
.slice(0, MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS)
887889
.map(
@@ -895,7 +897,7 @@ export async function collectWorkspaceSkillSymlinkEscapeFindings(params: {
895897
? `\n- +${escapedSkillFiles.length - MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS} more`
896898
: ""),
897899
remediation:
898-
"Keep workspace skills inside the workspace root (replace symlinked escapes with real in-workspace files), or move trusted shared skills to managed/bundled skill locations.",
900+
"Keep workspace skills inside the skills directory (replace symlinked escapes with real in-workspace files), or move trusted shared skills to managed/bundled skill locations or `skills.load.extraDirs`.",
899901
});
900902

901903
return findings;

src/security/audit.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1359,7 +1359,7 @@ description: test skill
13591359
},
13601360
},
13611361
{
1362-
name: "does not warn for workspace skills that stay inside workspace root",
1362+
name: "does not warn for workspace skills that stay inside skills directory",
13631363
supported: true,
13641364
setup: async () => {
13651365
const tmp = await makeTmpDir("workspace-skill-in-root");
@@ -1378,6 +1378,35 @@ description: test skill
13781378
expectNoFinding(res, "skills.workspace.symlink_escape");
13791379
},
13801380
},
1381+
{
1382+
name: "warns when skill symlink resolves inside workspace but outside skills directory",
1383+
supported: !isWindows,
1384+
setup: async () => {
1385+
const tmp = await makeTmpDir("workspace-skill-inside-ws-outside-skills");
1386+
const stateDir = path.join(tmp, "state");
1387+
const workspaceDir = path.join(tmp, "workspace");
1388+
const sharedSkillDir = path.join(workspaceDir, "shared", "skills", "my-skill");
1389+
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
1390+
await fs.mkdir(path.join(workspaceDir, "skills", "linked-skill"), { recursive: true });
1391+
await fs.mkdir(sharedSkillDir, { recursive: true });
1392+
await fs.writeFile(
1393+
path.join(sharedSkillDir, "SKILL.md"),
1394+
"# inside workspace, outside skills dir\n",
1395+
"utf-8",
1396+
);
1397+
// Symlink: skills/linked-skill/SKILL.md -> ../../shared/skills/my-skill/SKILL.md
1398+
await fs.symlink(
1399+
path.join(sharedSkillDir, "SKILL.md"),
1400+
path.join(workspaceDir, "skills", "linked-skill", "SKILL.md"),
1401+
);
1402+
return { stateDir, workspaceDir };
1403+
},
1404+
assert: (res: SecurityAuditReport) => {
1405+
const finding = res.findings.find((f) => f.checkId === "skills.workspace.symlink_escape");
1406+
expect(finding).toBeDefined();
1407+
expect(finding!.severity).toBe("warn");
1408+
},
1409+
},
13811410
] as const;
13821411

13831412
await runAuditCases(

0 commit comments

Comments
 (0)