fix(cli): harden managed install store

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 18:42:32 +00:00
1 parent 9241ebb8c6
commit 9d758ad730
5 files changed
+246 -31

No files matched your search

+23
View File
@@ -85,4 +85,27 @@ describe("managed install commands", () => {
expect(fs.existsSync(paths.shimPath)).toBe(false);
expect(fs.readFileSync(userData, "utf8")).toBe("keep");
});
it("rejects a symlinked installs root before npm writes outside the store", async () => {
const paths = resolveInstallStorePaths();
const outside = path.join(root, "outside");
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.mkdirSync(outside);
fs.symlinkSync(outside, paths.installsRoot, "dir");
const runCommand = vi.fn(async () => ({ stdout: JSON.stringify("2026.720.0"), stderr: "" }));
await expect(installCommand({}, { runCommand })).rejects.toThrow("non-directory install-store path");
expect(runCommand).toHaveBeenCalledTimes(1);
expect(fs.readdirSync(outside)).toEqual([]);
});
it("refuses to uninstall an unverified cli directory", async () => {
const paths = resolveInstallStorePaths();
const unrelatedFile = path.join(paths.cliRoot, "keep.txt");
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.writeFileSync(unrelatedFile, "keep");
await expect(uninstallCommand()).rejects.toThrow("unverified install store");
expect(fs.readFileSync(unrelatedFile, "utf8")).toBe("keep");
});
});
+43 -2
View File
@@ -8,11 +8,14 @@ import {
addManagedPathBlock,
buildNextManifest,
flipCurrentAtomic,
isManagedExecutable,
payloadPathFor,
pruneInstallPayloads,
readInstallManifest,
removeManagedPathBlock,
removeManagedShim,
resolveInstallStorePaths,
withInstallStoreLock,
writeInstallManifestAtomic,
writeManagedShim,
type InstallManifest,
@@ -101,10 +104,12 @@ describe("managed install store", () => {
expect(payloads.slice(1).every((payload) => fs.existsSync(payload))).toBe(true);
});
it("writes a stable shim and manages an idempotent shell PATH block", () => {
it("writes a stable shim with the validated runtime and custom store path", () => {
writeManagedShim(paths);
const shim = fs.readFileSync(paths.shimPath, "utf8");
expect(shim).toContain("$PAPERCLIP_HOME/cli/current/node_modules/paperclipai/dist/index.js");
expect(shim).toContain(process.execPath);
expect(shim).toContain(paths.currentPath);
expect(shim).not.toContain("PAPERCLIP_HOME");
expect(fs.statSync(paths.shimPath).mode & 0o777).toBe(0o755);
const rcPath = path.join(root, "home", ".bashrc");
@@ -114,6 +119,42 @@ describe("managed install store", () => {
expect(fs.readFileSync(rcPath, "utf8")).not.toContain("paperclipai managed PATH");
});
it("rejects marker substrings that are not the exact managed shim format", () => {
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
fs.writeFileSync(paths.shimPath, `#!/bin/sh\necho '${MANAGED_SHIM_MARKER}'\n`);
expect(removeManagedShim(paths)).toBe(false);
expect(fs.existsSync(paths.shimPath)).toBe(true);
expect(() => writeManagedShim(paths)).toThrow("non-managed command");
});
it("serializes install-store mutations with an exclusive lock", async () => {
await expect(
withInstallStoreLock(
() => withInstallStoreLock(async () => undefined, paths),
paths,
),
).rejects.toThrow("already running");
expect(fs.existsSync(paths.lockPath)).toBe(false);
});
it("reports managed provenance only for the payload selected by current", () => {
const manifestPayload = payloadPathFor(paths, "npm", "1.0.0");
const currentPayload = payloadPathFor(paths, "npm", "2.0.0");
const executable = path.join(manifestPayload, "node_modules", "paperclipai", "dist", "index.js");
fs.mkdirSync(path.dirname(executable), { recursive: true });
fs.writeFileSync(executable, "");
fs.mkdirSync(currentPayload, { recursive: true });
flipCurrentAtomic(currentPayload, paths);
const manifest: InstallManifest = {
schemaVersion: INSTALL_MANIFEST_VERSION,
...record(manifestPayload, "1.0.0"),
previous: [],
};
expect(isManagedExecutable(executable, manifest, paths)).toBe(false);
});
it("refuses symlinked payload roots and pre-existing non-managed shims", () => {
const outside = path.join(root, "outside");
fs.mkdirSync(outside, { recursive: true });
+31 -24
View File
@@ -13,6 +13,7 @@ import {
pruneInstallPayloads,
readInstallManifest,
resolveInstallStorePaths,
withInstallStoreLock,
writeInstallManifestAtomic,
writeManagedShim,
type InstallChannel,
@@ -88,8 +89,11 @@ async function smokePayload(payloadPath: string, expectedVersion: string, runCom
}
}
async function installNpmPayload(version: string, runCommand: CommandRunner): Promise<{ payloadPath: string; reused: boolean }> {
const paths = resolveInstallStorePaths();
async function installNpmPayload(
version: string,
runCommand: CommandRunner,
paths = resolveInstallStorePaths(),
): Promise<{ payloadPath: string; reused: boolean }> {
const payloadPath = payloadPathFor(paths, "npm", version);
if (fs.existsSync(payloadPath)) {
await smokePayload(payloadPath, version, runCommand);
@@ -185,28 +189,31 @@ export async function installCommand(
console.log(`Installing paperclipai@${version}...`);
const paths = resolveInstallStorePaths();
assertManagedShimWritable(paths);
const currentManifest = readInstallManifest(paths);
const installed = await installNpmPayload(version, runCommand);
const record: InstallRecord = {
source: "npm",
version,
channel: request.channel,
payloadPath: installed.payloadPath,
installedAt: (dependencies.now?.() ?? new Date()).toISOString(),
};
const nextManifest = buildNextManifest(record, currentManifest);
const oldTarget = fs.existsSync(paths.currentPath) ? fs.readlinkSync(paths.currentPath) : null;
flipCurrentAtomic(installed.payloadPath, paths);
try {
writeInstallManifestAtomic(nextManifest, paths);
} catch (error) {
if (oldTarget) flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths);
else fs.rmSync(paths.currentPath, { force: true });
throw error;
}
writeManagedShim(paths);
pruneInstallPayloads(nextManifest, paths);
const installed = await withInstallStoreLock(async () => {
assertManagedShimWritable(paths);
const currentManifest = readInstallManifest(paths);
const payload = await installNpmPayload(version, runCommand, paths);
const record: InstallRecord = {
source: "npm",
version,
channel: request.channel,
payloadPath: payload.payloadPath,
installedAt: (dependencies.now?.() ?? new Date()).toISOString(),
};
const nextManifest = buildNextManifest(record, currentManifest);
const oldTarget = fs.existsSync(paths.currentPath) ? fs.readlinkSync(paths.currentPath) : null;
flipCurrentAtomic(payload.payloadPath, paths);
try {
writeInstallManifestAtomic(nextManifest, paths);
} catch (error) {
if (oldTarget) flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths);
else fs.rmSync(paths.currentPath, { force: true });
throw error;
}
writeManagedShim(paths);
pruneInstallPayloads(nextManifest, paths);
return payload;
}, paths);
await ensureShimOnPath(options);
console.log(pc.green(`${installed.reused ? "Activated cached" : "Installed"} paperclipai ${version} (${request.channel}).`));
+2
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import pc from "picocolors";
import {
assertManagedInstallStore,
removeManagedPathBlock,
removeManagedShim,
resolveInstallStorePaths,
@@ -9,6 +10,7 @@ import {
export async function uninstallCommand(): Promise<void> {
const paths = resolveInstallStorePaths();
if (fs.existsSync(paths.cliRoot)) assertManagedInstallStore(paths);
const shimRemoved = removeManagedShim(paths);
fs.rmSync(paths.cliRoot, { recursive: true, force: true });
+147 -5
View File
@@ -3,7 +3,8 @@ import path from "node:path";
import { resolvePaperclipHomeDir } from "./config/home.js";
export const INSTALL_MANIFEST_VERSION = 1;
export const MANAGED_SHIM_MARKER = "paperclipai managed install shim";
export const MANAGED_SHIM_MARKER = "paperclipai managed install shim v1";
export const MANAGED_STORE_MARKER = "paperclipai managed install store v1\n";
export const PATH_BLOCK_START = "# >>> paperclipai managed PATH >>>";
export const PATH_BLOCK_END = "# <<< paperclipai managed PATH <<<";
@@ -31,6 +32,8 @@ export type InstallStorePaths = {
cliRoot: string;
installsRoot: string;
manifestPath: string;
markerPath: string;
lockPath: string;
currentPath: string;
shimPath: string;
};
@@ -76,11 +79,129 @@ export function resolveInstallStorePaths(options: {
cliRoot,
installsRoot: path.join(cliRoot, "installs"),
manifestPath: path.join(cliRoot, "install.json"),
markerPath: path.join(cliRoot, ".managed-install"),
lockPath: path.join(cliRoot, ".install.lock"),
currentPath: path.join(cliRoot, "current"),
shimPath: path.join(homeDir, ".local", "bin", "paperclipai"),
};
}
export function initializeInstallStore(paths = resolveInstallStorePaths()): void {
ensurePrivateDirectory(paths.cliRoot);
ensurePrivateDirectory(paths.installsRoot);
try {
const markerStat = fs.lstatSync(paths.markerPath);
if (!markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.nlink > 1) {
throw new Error(`Refusing to use unsafe install-store marker ${paths.markerPath}.`);
}
assertOwnedByCurrentUser(markerStat, paths.markerPath);
if (fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER) {
throw new Error(`Refusing to use unrecognized install store ${paths.cliRoot}.`);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
try {
fs.writeFileSync(paths.markerPath, MANAGED_STORE_MARKER, { mode: 0o600, flag: "wx" });
} catch (writeError) {
if (
(writeError as NodeJS.ErrnoException).code !== "EEXIST" ||
fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER
) {
throw writeError;
}
}
}
}
export function assertManagedInstallStore(paths = resolveInstallStorePaths()): InstallManifest {
const cliStat = fs.lstatSync(paths.cliRoot);
if (!cliStat.isDirectory() || cliStat.isSymbolicLink()) {
throw new Error(`Refusing to remove unsafe install-store path ${paths.cliRoot}.`);
}
assertOwnedByCurrentUser(cliStat, paths.cliRoot);
let markerStat: fs.Stats;
try {
markerStat = fs.lstatSync(paths.markerPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`);
}
throw error;
}
if (!markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.nlink > 1) {
throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`);
}
assertOwnedByCurrentUser(markerStat, paths.markerPath);
if (fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER) {
throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`);
}
const manifest = readInstallManifest(paths);
if (!manifest) throw new Error(`Refusing to remove install store without a manifest at ${paths.cliRoot}.`);
const relativePayload = path.relative(paths.installsRoot, path.resolve(manifest.payloadPath));
if (!relativePayload || relativePayload.startsWith("..") || path.isAbsolute(relativePayload)) {
throw new Error(`Refusing to remove install store with an invalid manifest at ${paths.cliRoot}.`);
}
return manifest;
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
export async function withInstallStoreLock<T>(
callback: () => Promise<T>,
paths = resolveInstallStorePaths(),
): Promise<T> {
initializeInstallStore(paths);
const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
const acquire = (): void => {
const temporaryPath = `${paths.lockPath}.${token}.tmp`;
try {
fs.writeFileSync(temporaryPath, `${token}\n`, { mode: 0o600, flag: "wx" });
try {
fs.linkSync(temporaryPath, paths.lockPath);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
const owner = fs.readFileSync(paths.lockPath, "utf8").trim();
const ownerPid = Number.parseInt(owner.split(":", 1)[0] ?? "", 10);
if (Number.isInteger(ownerPid) && ownerPid > 0 && processIsAlive(ownerPid)) {
throw new Error(`Another managed install is already running (pid ${ownerPid}).`);
}
fs.rmSync(paths.lockPath, { force: true });
try {
fs.linkSync(temporaryPath, paths.lockPath);
} catch (retryError) {
if ((retryError as NodeJS.ErrnoException).code === "EEXIST") {
throw new Error("Another managed install started while recovering a stale lock.");
}
throw retryError;
}
} finally {
fs.rmSync(temporaryPath, { force: true });
}
};
acquire();
try {
return await callback();
} finally {
try {
if (fs.readFileSync(paths.lockPath, "utf8").trim() === token) {
fs.rmSync(paths.lockPath, { force: true });
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
export function payloadPathFor(
paths: InstallStorePaths,
source: InstallSource,
@@ -246,7 +367,7 @@ export function assertManagedShimWritable(paths = resolveInstallStorePaths()): v
assertOwnedByCurrentUser(stat, paths.shimPath);
if (stat.nlink > 1) throw new Error(`Refusing to replace multiply linked shim ${paths.shimPath}.`);
const existing = fs.readFileSync(paths.shimPath, "utf8");
if (!existing.includes(MANAGED_SHIM_MARKER)) {
if (!isManagedShimContents(existing)) {
throw new Error(`Refusing to replace existing non-managed command ${paths.shimPath}.`);
}
} catch (error) {
@@ -254,6 +375,22 @@ export function assertManagedShimWritable(paths = resolveInstallStorePaths()): v
}
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function isManagedShimContents(contents: string): boolean {
const lines = contents.split("\n");
return (
lines.length === 5 &&
lines[0] === "#!/bin/sh" &&
lines[1] === `# ${MANAGED_SHIM_MARKER}` &&
lines[2] === "set -eu" &&
/^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[3]) &&
lines[4] === ""
);
}
export function writeManagedShim(paths = resolveInstallStorePaths()): void {
assertManagedShimWritable(paths);
const homeDir = path.dirname(path.dirname(path.dirname(paths.shimPath)));
@@ -262,14 +399,15 @@ export function writeManagedShim(paths = resolveInstallStorePaths()): void {
fs.mkdirSync(localDir, { mode: 0o755 });
fs.mkdirSync(path.dirname(paths.shimPath), { mode: 0o755 });
assertManagedShimWritable(paths);
const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nPAPERCLIP_HOME="\${PAPERCLIP_HOME:-\$HOME/.paperclip}"\nexec node "\$PAPERCLIP_HOME/cli/current/node_modules/paperclipai/dist/index.js" "\$@"\n`;
const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js");
const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`;
writeFileAtomic(paths.shimPath, contents, 0o755);
}
export function removeManagedShim(paths = resolveInstallStorePaths()): boolean {
try {
const contents = fs.readFileSync(paths.shimPath, "utf8");
if (!contents.includes(MANAGED_SHIM_MARKER)) return false;
if (!isManagedShimContents(contents)) return false;
fs.rmSync(paths.shimPath, { force: true });
return true;
} catch (error) {
@@ -333,7 +471,11 @@ export function isManagedExecutable(
try {
const executableRealPath = fs.realpathSync(executablePath);
const payloadRealPath = fs.realpathSync(manifest.payloadPath);
return executableRealPath.startsWith(`${payloadRealPath}${path.sep}`) && fs.existsSync(paths.currentPath);
const currentRealPath = fs.realpathSync(paths.currentPath);
return (
currentRealPath === payloadRealPath &&
executableRealPath.startsWith(`${payloadRealPath}${path.sep}`)
);
} catch {
return false;
}