(
skillDir: string,
filePath: string,
options?: { allowMissingLeaf?: boolean }
)
| 131 | } |
| 132 | |
| 133 | export async function resolveContainedSkillFilePath( |
| 134 | skillDir: string, |
| 135 | filePath: string, |
| 136 | options?: { allowMissingLeaf?: boolean } |
| 137 | ): Promise<{ resolvedPath: string; normalizedRelativePath: string }> { |
| 138 | const { resolvedPath: requestedPath, normalizedRelativePath } = resolveSkillFilePath( |
| 139 | skillDir, |
| 140 | filePath |
| 141 | ); |
| 142 | |
| 143 | const rootReal = options?.allowMissingLeaf |
| 144 | ? await resolveRealPathAllowMissing(skillDir) |
| 145 | : await fsPromises.realpath(skillDir); |
| 146 | const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`; |
| 147 | |
| 148 | const targetReal = options?.allowMissingLeaf |
| 149 | ? await resolveRealPathAllowMissing(requestedPath) |
| 150 | : await fsPromises.realpath(requestedPath); |
| 151 | |
| 152 | if (targetReal !== rootReal && !targetReal.startsWith(rootPrefix)) { |
| 153 | throw new Error( |
| 154 | `Invalid filePath (path escapes skill directory after symlink resolution): ${filePath}` |
| 155 | ); |
| 156 | } |
| 157 | |
| 158 | // Use the resolved real path only for containment checks; callers must mutate the lexical |
| 159 | // requested path so lstat-based leaf symlink rejection checks inspect the requested alias. |
| 160 | return { |
| 161 | resolvedPath: requestedPath, |
| 162 | normalizedRelativePath, |
| 163 | }; |
| 164 | } |
| 165 | |
| 166 | /** |
| 167 | * Unified directory-scope validation for local skill operations (write / delete). |
no test coverage detected