paperclipai/paperclip · error · Error
Refusing to write service definition through unsafe director
Error message
Refusing to write service definition through unsafe directory ${directoryPath}. What it means
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.
Source
Thrown at cli/src/services/service-manager.ts:142
<key>PAPERCLIP_INSTANCE_ID</key><string>${escapeXml(input.instanceId)}</string>
<key>PAPERCLIP_HOME</key><string>${escapeXml(input.homeDir)}</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>ExitTimeOut</key><integer>300</integer>
<key>StandardOutPath</key><string>${escapeXml(input.stdoutPath)}</string>
<key>StandardErrorPath</key><string>${escapeXml(input.stderrPath)}</string>
</dict>
</plist>
`;
}
async function writeIfChanged(filePath: string, contents: string): Promise<boolean> {
const directoryPath = path.dirname(filePath);
await fs.mkdir(directoryPath, { recursive: true, mode: 0o700 });
const directoryStat = await fs.lstat(directoryPath);
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing to write service definition through unsafe directory ${directoryPath}.`);
const currentUid = process.getuid?.();
if (currentUid !== undefined && directoryStat.uid !== currentUid) throw new Error(`Refusing to write service definition in directory not owned by the current user: ${directoryPath}.`);
try {
const stat = await fs.lstat(filePath);
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new Error(`Refusing to replace unsafe service definition ${filePath}.`);
if (currentUid !== undefined && stat.uid !== currentUid) throw new Error(`Refusing to replace service definition not owned by the current user: ${filePath}.`);
if (await fs.readFile(filePath, "utf8") === contents) return false;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const temporaryPath = path.join(directoryPath, `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}`);
try {
await fs.writeFile(temporaryPath, contents, { encoding: "utf8", mode: 0o644, flag: "wx" });
await fs.rename(temporaryPath, filePath);
} finally {
await fs.rm(temporaryPath, { force: true });
}
return true;View on GitHub (pinned to 67001ec6eb)
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.
Example fix
// before: ~/.config/systemd/user is a symlink // ls -la ~/.config/systemd/ -> user -> /tmp/evil // after: real directory // rm ~/.config/systemd/user // mkdir -p ~/.config/systemd/user // chmod 700 ~/.config/systemd/user
Defensive patterns
Strategy: validation
Validate before calling
import fs from 'node:fs/promises';
import path from 'node:path';
async function isSafeServiceDir(dirPath: string): Promise<boolean> {
try {
const stat = await fs.lstat(dirPath);
return stat.isDirectory() && !stat.isSymbolicLink();
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
}
// Call before service install:
const serviceDir = path.dirname(definitionPath);
if (!(await isSafeServiceDir(serviceDir))) {
throw new Error(`Service directory ${serviceDir} is unsafe (symlink or non-directory).`);
} Try / catch
try {
await manager.install({ startNow: true, startOnLogin: true });
} catch (error) {
if (error instanceof Error && error.message.includes('unsafe directory')) {
// The service config directory was replaced with a symlink; fix it
console.error('Fix the service directory before retrying:', error.message);
}
throw error;
} Prevention
- 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.
When it happens
Trigger: Calling install(), start(), restart(), or ensureCurrent() on SystemdServiceManager or LaunchdServiceManager when the service definition directory resolves to a symlink or non-directory after creation.
Common situations: 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.
Related errors
- Refusing to replace unsafe service definition ${filePath}.
- Refusing to write service definition in directory not owned
- Systemd service values must not contain line breaks
- Refusing to activate payload that resolves outside ${paths.i
- Refusing to prune unsafe install-store path ${sourceRoot}.
AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12).
Data as JSON: /api/errors/59c7b5b33949247b.
Report an issue: GitHub.