|
1 | | -import { promises as fs } from 'node:fs'; |
| 1 | +import { constants, promises as fs } from 'node:fs'; |
2 | 2 |
|
3 | 3 | import path from 'pathe'; |
4 | 4 |
|
| 5 | +import { resolveSafePath } from '../../services/fs/fsPathSafety'; |
5 | 6 | import { normalizeSkillName } from '../../skill/types'; |
6 | 7 |
|
7 | 8 | /** |
@@ -123,24 +124,73 @@ export function renderSavedWorkflowSkill(workflow: SavedWorkflow): string { |
123 | 124 | * Lives here rather than in the slash command so every surface that can run a |
124 | 125 | * workflow can also keep one. The name is validated before any directory is |
125 | 126 | * created, so a rejected name leaves nothing behind. |
| 127 | + * |
| 128 | + * A validated name is not enough on its own. Agents work in repositories they |
| 129 | + * did not write, and a checked-out tree can already contain |
| 130 | + * `.pythinker-code/skills/<name>/SKILL.md` as a symlink pointing anywhere on |
| 131 | + * the machine — saving a workflow would then write through it. The resolved |
| 132 | + * path is checked against the scope root before the write, and the write |
| 133 | + * itself refuses to follow a final symlink, so neither a planted link nor one |
| 134 | + * swapped in afterwards is followed. |
126 | 135 | */ |
127 | 136 | export async function writeSavedWorkflowSkill(input: { |
128 | 137 | readonly scope: SavedWorkflowScope; |
129 | 138 | readonly workflow: SavedWorkflow; |
130 | 139 | readonly projectRoot: string; |
131 | 140 | readonly brandHomeDir: string; |
132 | 141 | }): Promise<string> { |
| 142 | + const name = savedWorkflowSkillName(input.workflow.name); |
| 143 | + const root = input.scope === 'project' ? input.projectRoot : input.brandHomeDir; |
133 | 144 | const dir = savedWorkflowSkillDir({ |
134 | 145 | scope: input.scope, |
135 | 146 | name: input.workflow.name, |
136 | 147 | projectRoot: input.projectRoot, |
137 | 148 | brandHomeDir: input.brandHomeDir, |
138 | 149 | }); |
139 | | - const content = renderSavedWorkflowSkill({ |
140 | | - ...input.workflow, |
141 | | - name: savedWorkflowSkillName(input.workflow.name), |
142 | | - }); |
| 150 | + const content = renderSavedWorkflowSkill({ ...input.workflow, name }); |
| 151 | + const skillMdPath = path.join(dir, 'SKILL.md'); |
| 152 | + |
| 153 | + // Before `mkdir`, not after: a symlinked `skills/` would otherwise have a |
| 154 | + // directory created through it and left behind outside the project even |
| 155 | + // though the write was refused. |
| 156 | + await assertResolvesInsideRoot(root, dir); |
143 | 157 | await fs.mkdir(dir, { recursive: true }); |
144 | | - await fs.writeFile(path.join(dir, 'SKILL.md'), content, 'utf8'); |
| 158 | + // Again once the directory exists, so a `SKILL.md` symlink planted inside it |
| 159 | + // is resolved rather than treated as the not-yet-existing tail. |
| 160 | + await assertResolvesInsideRoot(root, skillMdPath); |
| 161 | + await writeFileNoFollow(skillMdPath, content); |
145 | 162 | return dir; |
146 | 163 | } |
| 164 | + |
| 165 | +/** Throws when `target` resolves outside `root` once every symlink is followed. */ |
| 166 | +async function assertResolvesInsideRoot(root: string, target: string): Promise<void> { |
| 167 | + // `target` was built from `root` verbatim, so take the relative path against |
| 168 | + // that same un-resolved root. Measuring it against the realpath instead makes |
| 169 | + // every `/var` -> `/private/var` style link look like a `..` escape. |
| 170 | + const relative = path.relative(root, target); |
| 171 | + // `resolveSafePath` rejects an absolute or `..`-bearing input outright, and |
| 172 | + // otherwise resolves the longest existing prefix through its symlinks before |
| 173 | + // checking containment — which is exactly the planted-link case. |
| 174 | + await resolveSafePath(await fs.realpath(root), relative); |
| 175 | +} |
| 176 | + |
| 177 | +/** |
| 178 | + * Write without following a symlink at the final path component. |
| 179 | + * |
| 180 | + * `O_NOFOLLOW` is POSIX; on a platform without it the constant is undefined and |
| 181 | + * the containment check above is the guard, so the flag is added only when the |
| 182 | + * runtime offers it rather than failing the save outright. |
| 183 | + */ |
| 184 | +async function writeFileNoFollow(target: string, content: string): Promise<void> { |
| 185 | + const noFollow = constants.O_NOFOLLOW ?? 0; |
| 186 | + const handle = await fs.open( |
| 187 | + target, |
| 188 | + constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | noFollow, |
| 189 | + 0o600, |
| 190 | + ); |
| 191 | + try { |
| 192 | + await handle.writeFile(content, 'utf8'); |
| 193 | + } finally { |
| 194 | + await handle.close(); |
| 195 | + } |
| 196 | +} |
0 commit comments