( containmentRoot: string, skillDir: string )
| 175 | * Throws a descriptive error string on any violation. |
| 176 | */ |
| 177 | export async function validateLocalSkillDirectory( |
| 178 | containmentRoot: string, |
| 179 | skillDir: string |
| 180 | ): Promise<{ skillDirStat: Stats | null }> { |
| 181 | // 1) Reject symlinked skills root |
| 182 | const skillsRoot = path.dirname(skillDir); |
| 183 | const skillsRootStat = await lstatIfExists(skillsRoot); |
| 184 | if (skillsRootStat?.isSymbolicLink()) { |
| 185 | throw new Error( |
| 186 | "Skills root directory is a symbolic link and cannot be used for skill operations." |
| 187 | ); |
| 188 | } |
| 189 | |
| 190 | // 2) Reject symlinked skill directory |
| 191 | const skillDirStat = await lstatIfExists(skillDir); |
| 192 | if (skillDirStat?.isSymbolicLink()) { |
| 193 | throw new Error("Skill directory is a symlink (symbolic link) and cannot be modified."); |
| 194 | } |
| 195 | |
| 196 | // 3) Verify realpath stays under containmentRoot (even for missing dirs via allow-missing resolution) |
| 197 | await ensurePathContained(containmentRoot, skillDir, { allowMissing: true }); |
| 198 | |
| 199 | return { skillDirStat }; |
| 200 | } |
| 201 | |
| 202 | /** Canonical filename for the skill definition file. */ |
| 203 | export const SKILL_FILENAME = "SKILL.md"; |
no test coverage detected