{"record":{"id":"59c7b5b33949247b","repo":"paperclipai/paperclip","slug":"refusing-to-write-service-definition-through-unsaf","errorCode":null,"errorMessage":"Refusing to write service definition through unsafe directory ${directoryPath}.","messagePattern":"Refusing to write service definition through unsafe directory (.+?)\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"cli/src/services/service-manager.ts","lineNumber":142,"sourceCode":"    <key>PAPERCLIP_INSTANCE_ID</key><string>${escapeXml(input.instanceId)}</string>\n    <key>PAPERCLIP_HOME</key><string>${escapeXml(input.homeDir)}</string>\n  </dict>\n  <key>RunAtLoad</key><true/>\n  <key>KeepAlive</key><true/>\n  <key>ThrottleInterval</key><integer>5</integer>\n  <key>ExitTimeOut</key><integer>300</integer>\n  <key>StandardOutPath</key><string>${escapeXml(input.stdoutPath)}</string>\n  <key>StandardErrorPath</key><string>${escapeXml(input.stderrPath)}</string>\n</dict>\n</plist>\n`;\n}\n\nasync function writeIfChanged(filePath: string, contents: string): Promise<boolean> {\n  const directoryPath = path.dirname(filePath);\n  await fs.mkdir(directoryPath, { recursive: true, mode: 0o700 });\n  const directoryStat = await fs.lstat(directoryPath);\n  if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing to write service definition through unsafe directory ${directoryPath}.`);\n  const currentUid = process.getuid?.();\n  if (currentUid !== undefined && directoryStat.uid !== currentUid) throw new Error(`Refusing to write service definition in directory not owned by the current user: ${directoryPath}.`);\n  try {\n    const stat = await fs.lstat(filePath);\n    if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new Error(`Refusing to replace unsafe service definition ${filePath}.`);\n    if (currentUid !== undefined && stat.uid !== currentUid) throw new Error(`Refusing to replace service definition not owned by the current user: ${filePath}.`);\n    if (await fs.readFile(filePath, \"utf8\") === contents) return false;\n  } catch (error) {\n    if ((error as NodeJS.ErrnoException).code !== \"ENOENT\") throw error;\n  }\n  const temporaryPath = path.join(directoryPath, `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}`);\n  try {\n    await fs.writeFile(temporaryPath, contents, { encoding: \"utf8\", mode: 0o644, flag: \"wx\" });\n    await fs.rename(temporaryPath, filePath);\n  } finally {\n    await fs.rm(temporaryPath, { force: true });\n  }\n  return true;","sourceCodeStart":124,"sourceCodeEnd":160,"githubUrl":"https://github.com/paperclipai/paperclip/blob/67001ec6eb96ae601aa27bc91d9b2415d665334a/cli/src/services/service-manager.ts#L124-L160","documentation":"Thrown by writeIfChanged in service-manager.ts when the parent directory of the service definition file (e.g., ~/.config/systemd/user/ for systemd, ~/Library/LaunchAgents/ for launchd) exists after mkdir but is not a real directory or is a symbolic link. This is a TOCTOU guard: even though mkdir created the directory, lstat is called afterward to verify it was not replaced with a symlink before the file is written, preventing writes through an attacker-controlled symlink path.","triggerScenarios":"Calling install(), start(), restart(), or ensureCurrent() on SystemdServiceManager or LaunchdServiceManager when the service definition directory resolves to a symlink or non-directory after creation.","commonSituations":"A symlink attack replaced the service directory between mkdir and lstat. The user's ~/.config/systemd/user/ or ~/Library/LaunchAgents/ is symlinked to another location. A security tool or filesystem event substituted the directory.","solutions":["Inspect the service definition directory: 'ls -la ~/.config/systemd/user/' (systemd) or 'ls -la ~/Library/LaunchAgents/' (launchd).","If it is a symlink, replace it with a real directory: remove the symlink and mkdir -p the path.","Verify directory permissions are restrictive (the code creates with mode 0o700).","Retry the service install command."],"exampleFix":"// before: ~/.config/systemd/user is a symlink\n// ls -la ~/.config/systemd/ -> user -> /tmp/evil\n\n// after: real directory\n// rm ~/.config/systemd/user\n// mkdir -p ~/.config/systemd/user\n// chmod 700 ~/.config/systemd/user","handlingStrategy":"validation","validationCode":"import fs from 'node:fs/promises';\nimport path from 'node:path';\n\nasync function isSafeServiceDir(dirPath: string): Promise<boolean> {\n  try {\n    const stat = await fs.lstat(dirPath);\n    return stat.isDirectory() && !stat.isSymbolicLink();\n  } catch (error) {\n    return (error as NodeJS.ErrnoException).code === 'ENOENT';\n  }\n}\n\n// Call before service install:\nconst serviceDir = path.dirname(definitionPath);\nif (!(await isSafeServiceDir(serviceDir))) {\n  throw new Error(`Service directory ${serviceDir} is unsafe (symlink or non-directory).`);\n}","typeGuard":null,"tryCatchPattern":"try {\n  await manager.install({ startNow: true, startOnLogin: true });\n} catch (error) {\n  if (error instanceof Error && error.message.includes('unsafe directory')) {\n    // The service config directory was replaced with a symlink; fix it\n    console.error('Fix the service directory before retrying:', error.message);\n  }\n  throw error;\n}","preventionTips":["Ensure ~/.config/systemd/user/ (Linux) and ~/Library/LaunchAgents/ (macOS) are real directories.","Do not symlink these directories to shared or network locations.","If a security tool or backup software modifies these directories, verify they are still real directories afterward."],"tags":["security","service-manager","symlink","systemd","launchd","cli"],"backgroundTag":null,"analyzedSha":"67001ec6eb96ae601aa27bc91d9b2415d665334a","analyzedAt":"2026-08-12T12:05:45.408Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}