{"record":{"id":"2c56b4a054da7d66","repo":"can1357/oh-my-pi","slug":"managed-skill-name-resolves-through-a-symlink","errorCode":null,"errorMessage":"Managed skill \"${name}\" resolves through a symlink; refusing to write outside the managed directory.","messagePattern":"Managed skill \"(.+?)\" resolves through a symlink; refusing to write outside the managed directory\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/autolearn/managed-skills.ts","lineNumber":186,"sourceCode":"\tconst bytes = Buffer.byteLength(content, \"utf8\");\n\tif (bytes > MAX_MANAGED_SKILL_BYTES) {\n\t\tthrow new Error(\n\t\t\t`Managed skill is ${bytes} bytes; the limit is ${MAX_MANAGED_SKILL_BYTES}. Trim the body or description.`,\n\t\t);\n\t}\n\treturn serializeSkillMutation(name, async () => {\n\t\tawait assertManagedRootSafe();\n\t\tconst dir = path.join(getManagedSkillsDir(), name);\n\t\tconst file = path.join(dir, \"SKILL.md\");\n\t\t// Reject a symlinked skill directory: an intermediate symlink would let the\n\t\t// write escape the isolated managed root. lstat does not follow the final\n\t\t// component, so a symlinked `dir` is caught here.\n\t\tconst dirStat = await fs.lstat(dir).catch(err => {\n\t\t\tif (isEnoent(err)) return null;\n\t\t\tthrow err;\n\t\t});\n\t\tif (dirStat?.isSymbolicLink()) {\n\t\t\tthrow new Error(\n\t\t\t\t`Managed skill \"${name}\" resolves through a symlink; refusing to write outside the managed directory.`,\n\t\t\t);\n\t\t}\n\t\tif (input.action === \"create\") {\n\t\t\tawait fs.mkdir(dir, { recursive: true });\n\t\t\t// O_CREAT|O_EXCL (\"wx\"): atomic create that fails if the file already\n\t\t\t// exists (closing the check-then-write race) and refuses a symlinked SKILL.md.\n\t\t\ttry {\n\t\t\t\tawait fs.writeFile(file, content, { flag: \"wx\" });\n\t\t\t} catch (err) {\n\t\t\t\tif ((err as { code?: string }).code === \"EEXIST\") {\n\t\t\t\t\tthrow new Error(`Managed skill \"${name}\" already exists. Use action \"update\" to change it.`);\n\t\t\t\t}\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t\treturn { path: file };\n\t\t}\n\t\t// update: the file must already exist, be a plain managed file, and must","sourceCodeStart":168,"sourceCodeEnd":204,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/autolearn/managed-skills.ts#L168-L204","documentation":"Inside writeManagedSkill, after checking the root, the code lstats the per-skill directory <managed-skills>/<name> and refuses if that directory is itself a symlink — the write could then land outside the managed root. This closes the traversal hole the root check cannot catch (a legit root containing a linked subdirectory).","triggerScenarios":"writeManagedSkill targets a skill name whose directory under the managed root is a symlink to another location.","commonSituations":"A user symlinked a managed skill folder to a shared/location-controlled directory; stow-style dotfile management linked whole skill directories; a malicious or buggy setup script pre-created linked dirs to redirect auto-generated content.","solutions":["Remove the symlinked skill directory: `rm ~/.omp/agent/managed-skills/<name>` (removes the link only), then retry — writeManagedSkill will mkdir a real directory","Never symlink directories into managed-skills; copy content instead or use the authored skills dir","Audit ~/.omp/agent/managed-skills with `find -type l` to find remaining symlinks"],"exampleFix":"// before (shell)\nln -s /shared/skills/foo ~/.omp/agent/managed-skills/foo\n// after (shell)\nrm ~/.omp/agent/managed-skills/foo  # drop the link; retry writeManagedSkill to recreate a real dir","handlingStrategy":"validation","validationCode":"import { lstat } from \"node:fs/promises\";\nimport * as path from \"node:path\";\nconst dir = path.join(getManagedSkillsDir(), name);\nconst st = await lstat(dir).catch(() => null);\nif (st?.isSymbolicLink()) throw new Error(\"skill dir must not be a symlink\");","typeGuard":"function isRealDirStat(st: { isSymbolicLink(): boolean; isDirectory(): boolean } | null): boolean {\n  return st !== null && st.isDirectory() && !st.isSymbolicLink();\n}","tryCatchPattern":"try {\n  await writeManagedSkill(input);\n} catch (err) {\n  if (String((err as Error).message).includes(\"resolves through a symlink\")) {\n    // rm the linked skill dir and retry so a real directory is created\n  } else throw err;\n}","preventionTips":["Never symlink directories into ~/.omp/agent/managed-skills; copy instead","Audit with `find ~/.omp/agent/managed-skills -type l` after external setup tools run","Use the authored skills dir for shared/linked skill content"],"tags":["security","symlink","filesystem"],"backgroundTag":"symlink-security-guard","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}