{"record":{"id":"fb24c5ba91ea0570","repo":"paperclipai/paperclip","slug":"refusing-to-replace-unsafe-service-definition-fi","errorCode":null,"errorMessage":"Refusing to replace unsafe service definition ${filePath}.","messagePattern":"Refusing to replace unsafe service definition (.+?)\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"cli/src/services/service-manager.ts","lineNumber":147,"sourceCode":"  <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;\n}\n\nexport class SystemdServiceManager implements ServiceManager {\n  readonly platform = \"systemd\" as const;\n  readonly serviceName: string;","sourceCodeStart":129,"sourceCodeEnd":165,"githubUrl":"https://github.com/paperclipai/paperclip/blob/67001ec6eb96ae601aa27bc91d9b2415d665334a/cli/src/services/service-manager.ts#L129-L165","documentation":"Thrown by writeIfChanged when the existing service definition file (e.g., paperclipai.service or the launchd plist) is not a regular file, is a symbolic link, or has a hard link count greater than 1. This triple check (regular-file + not-symlink + nlink<=1) prevents replacing an attacker-controlled symlink or a multiply-linked file that could cause the atomic rename to affect other paths.","triggerScenarios":"Calling install/start/restart on a service manager when the service definition file already exists as a symlink, a non-regular file, or a hard-linked file.","commonSituations":"A symlink attack placed a symlink at the service definition path. A dotfile manager symlinks the service file. The service file was manually hard-linked to another location for backup.","solutions":["Inspect the service definition file: 'ls -la ~/.config/systemd/user/paperclipai.service' (systemd) or the plist path (launchd).","If it is a symlink, remove it: 'rm ~/.config/systemd/user/paperclipai.service'.","If it is hard-linked (nlink > 1), remove the extra links or delete the file so the installer can recreate it.","Retry the service install command."],"exampleFix":"// before: service file is a symlink\n// ls -la ~/.config/systemd/user/paperclipai.service -> lrwxrwxrwx\n\n// after: remove symlink\n// rm ~/.config/systemd/user/paperclipai.service\n// re-run: paperclipai service install","handlingStrategy":"validation","validationCode":"import fs from 'node:fs';\n\nfunction isSafeServiceDefinition(filePath: string): boolean {\n  try {\n    const stat = fs.lstatSync(filePath);\n    return stat.isFile() && !stat.isSymbolicLink() && stat.nlink <= 1;\n  } catch (error) {\n    return (error as NodeJS.ErrnoException).code === 'ENOENT';\n  }\n}\n\n// Call before service install:\nif (!isSafeServiceDefinition(manager.definitionPath)) {\n  console.warn('Service definition is unsafe (symlink/hardlinked); removing before reinstall.');\n  fs.rmSync(manager.definitionPath, { force: true });\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 service definition')) {\n    // Remove the unsafe file and retry\n    await fs.rm(manager.definitionPath, { force: true });\n    await manager.install({ startNow: true, startOnLogin: true });\n  } else {\n    throw error;\n  }\n}","preventionTips":["Do not symlink or hard-link service definition files.","If a dotfile manager manages service files, exclude paperclip service files from its management.","Run 'ls -la' on the service definition path before install to verify it is a regular file or does not exist."],"tags":["security","service-manager","symlink","hardlink","systemd","launchd","cli"],"backgroundTag":null,"analyzedSha":"67001ec6eb96ae601aa27bc91d9b2415d665334a","analyzedAt":"2026-08-12T12:05:45.408Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}