fix(server): stop requiring PAPERCLIP_DECISION_SIGNING_SECRET at startup (#10594)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server signs decision specifications with an HMAC
> - PR #10010 made `PAPERCLIP_DECISION_SIGNING_SECRET` a hard startup
requirement
> - Existing installs do not have this new environment variable
> - Those installs now stop during startup
> - This pull request uses a secure persisted instance key when the
override is absent
> - The benefit is that existing installs start without new
configuration and decision signing remains fail-closed

## Linked Issues or Issue Description

**What happened?**

After #10010, `startServer()` throws when
`PAPERCLIP_DECISION_SIGNING_SECRET` is unset or shorter than 32
characters. Existing installs without the new environment variable stop
at startup.

**Expected behavior**

The server starts without manual configuration. A new optional feature
must not add a required environment variable for existing installs.

**Steps to reproduce**

1. Check out `master` at 9c1f8e7887.
2. Unset `PAPERCLIP_DECISION_SIGNING_SECRET`.
3. Start the server.
4. Observe that startup stops with a missing-secret error.

**Paperclip version or commit**

`master` at 9c1f8e7887.

**Deployment mode**

All deployment modes are affected when the environment variable is
absent.

## What Changed

- Treat `PAPERCLIP_DECISION_SIGNING_SECRET` as an optional override.
- Generate a random per-instance key at
`<instance>/secrets/decision-signing.key` when the override is absent.
- Publish a complete first-time key with an atomic no-overwrite link so
concurrent server starts use one key.
- Repair permissive modes on process-owned secrets directories and
regular key files, reject planted symlinks or foreign-owned paths, and
fail startup if `0700`/`0600` cannot be enforced.
- Keep an explicitly configured secret shorter than 32 characters as a
startup error.
- Add startup, permission, planted-symlink, fail-closed verification,
and generated-key round-trip tests.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/decisions-service.test.ts
src/__tests__/server-startup-feedback-export.test.ts` — 45 tests passed.
- `pnpm --filter @paperclipai/server exec tsc --noEmit` — passed.
- Eight simultaneous resolver processes returned the same persisted key.
The secrets directory/key modes were `0700`/`0600`.
- `git diff --check` — passed.

## Risks

- Existing configured secrets remain unchanged.
- Removing a configured secret after a proposal makes the prior
signature fail verification. Restoring the secret restores verification.
- A restored secrets directory or key with unsafe permissions now fails
startup when the server cannot repair it to `0700`/`0600`; symlinks and
paths owned by another local user are rejected rather than trusted.
- The generated key uses an atomic hard link in the instance secrets
directory. An unsupported file system fails startup instead of replacing
an existing key.
- Existing installs that failed at startup did not sign decisions with a
missing key.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Fable 5, model ID `claude-fable-5`, produced the
initial implementation with extended reasoning and tool use.
- OpenAI Codex, model ID `gpt-5`, addressed review findings and prepared
the PR with reasoning, repository editing, code execution, and GitHub
tooling. The runtime did not expose the context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-31 22:48:32 -07:00 committed by GitHub
parent b163c6c473
commit 92c3d9f0d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 263 additions and 14 deletions

View File

@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
@ -279,13 +282,38 @@ describePg("decisionService", () => {
expect(result.executions[0]?.result).toMatchObject({ originReason: "deny_missing_membership" });
});
it("refuses execution when the decision signing secret is unavailable", async () => {
it("fails closed when the configured signing secret is removed after proposal", async () => {
const created = await createCommentDecision();
const originalHome = process.env.PAPERCLIP_HOME;
const tempHome = mkdtempSync(path.join(tmpdir(), "paperclip-decision-rotate-"));
process.env.PAPERCLIP_HOME = tempHome;
delete process.env.PAPERCLIP_DECISION_SIGNING_SECRET;
process.env.PAPERCLIP_AGENT_JWT_SECRET = "agent-jwt-secret-must-not-sign-decisions";
await expect(service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() }))
.rejects.toThrow("PAPERCLIP_DECISION_SIGNING_SECRET is required");
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(0);
try {
await expect(service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() }))
.rejects.toThrow("Decision signature verification failed");
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(0);
} finally {
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalHome;
rmSync(tempHome, { recursive: true, force: true });
}
});
it("signs and verifies with an auto-generated key when no secret is configured", async () => {
const originalHome = process.env.PAPERCLIP_HOME;
const tempHome = mkdtempSync(path.join(tmpdir(), "paperclip-decision-generated-"));
process.env.PAPERCLIP_HOME = tempHome;
delete process.env.PAPERCLIP_DECISION_SIGNING_SECRET;
try {
const created = await createCommentDecision();
const result = await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
expect(result.executionStatus).toBe("succeeded");
} finally {
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalHome;
rmSync(tempHome, { recursive: true, force: true });
}
});
it("records a failed effect and continues independent later effects", async () => {

View File

@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
const ORIGINAL_PAPERCLIP_API_URL = process.env.PAPERCLIP_API_URL;
const ORIGINAL_PAPERCLIP_RUNTIME_API_URL = process.env.PAPERCLIP_RUNTIME_API_URL;
@ -298,9 +301,92 @@ describe("startServer feedback export wiring", () => {
process.env.BETTER_AUTH_SECRET = "test-secret";
});
it("refuses startup when the decision signing secret is unavailable", async () => {
it("starts without PAPERCLIP_DECISION_SIGNING_SECRET by generating a persisted key", async () => {
const originalHome = process.env.PAPERCLIP_HOME;
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const tempHome = mkdtempSync(path.join(tmpdir(), "paperclip-decision-key-"));
process.env.PAPERCLIP_HOME = tempHome;
process.env.PAPERCLIP_INSTANCE_ID = "default";
delete process.env.PAPERCLIP_DECISION_SIGNING_SECRET;
await expect(startServer()).rejects.toThrow("PAPERCLIP_DECISION_SIGNING_SECRET is required");
try {
const started = await startServer();
expect(started.server).toBe(fakeServer);
const keyPath = path.join(tempHome, "instances", "default", "secrets", "decision-signing.key");
expect(readFileSync(keyPath, "utf8").trim().length).toBeGreaterThanOrEqual(32);
if (process.platform !== "win32") {
expect(statSync(path.dirname(keyPath)).mode & 0o777).toBe(0o700);
expect(statSync(keyPath).mode & 0o777).toBe(0o600);
}
} finally {
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalHome;
if (originalInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
rmSync(tempHome, { recursive: true, force: true });
}
});
it("repairs permissive permissions on an existing generated decision signing key", async () => {
const originalHome = process.env.PAPERCLIP_HOME;
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const tempHome = mkdtempSync(path.join(tmpdir(), "paperclip-decision-key-mode-"));
const keyPath = path.join(tempHome, "instances", "default", "secrets", "decision-signing.key");
const existingKey = Buffer.alloc(32, 7).toString("base64");
mkdirSync(path.dirname(keyPath), { recursive: true, mode: 0o777 });
chmodSync(path.dirname(keyPath), 0o777);
writeFileSync(keyPath, existingKey, { encoding: "utf8", mode: 0o644 });
chmodSync(keyPath, 0o644);
process.env.PAPERCLIP_HOME = tempHome;
process.env.PAPERCLIP_INSTANCE_ID = "default";
delete process.env.PAPERCLIP_DECISION_SIGNING_SECRET;
try {
const started = await startServer();
expect(started.server).toBe(fakeServer);
expect(readFileSync(keyPath, "utf8")).toBe(existingKey);
if (process.platform !== "win32") {
expect(statSync(path.dirname(keyPath)).mode & 0o777).toBe(0o700);
expect(statSync(keyPath).mode & 0o777).toBe(0o600);
}
} finally {
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalHome;
if (originalInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
rmSync(tempHome, { recursive: true, force: true });
}
});
it("refuses a symlink planted as the generated decision signing key", async () => {
if (process.platform === "win32") return;
const originalHome = process.env.PAPERCLIP_HOME;
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const tempHome = mkdtempSync(path.join(tmpdir(), "paperclip-decision-key-symlink-"));
const keyPath = path.join(tempHome, "instances", "default", "secrets", "decision-signing.key");
const plantedTarget = path.join(tempHome, "planted.key");
const plantedKey = Buffer.alloc(32, 9).toString("base64");
mkdirSync(path.dirname(keyPath), { recursive: true, mode: 0o777 });
chmodSync(path.dirname(keyPath), 0o777);
writeFileSync(plantedTarget, plantedKey, { encoding: "utf8", mode: 0o600 });
symlinkSync(plantedTarget, keyPath);
process.env.PAPERCLIP_HOME = tempHome;
process.env.PAPERCLIP_INSTANCE_ID = "default";
delete process.env.PAPERCLIP_DECISION_SIGNING_SECRET;
try {
await expect(startServer()).rejects.toThrow("must be a regular file");
expect(readFileSync(plantedTarget, "utf8")).toBe(plantedKey);
} finally {
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalHome;
if (originalInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
rmSync(tempHome, { recursive: true, force: true });
}
});
it("refuses startup when an explicit decision signing secret is too short", async () => {
process.env.PAPERCLIP_DECISION_SIGNING_SECRET = "too-short";
await expect(startServer()).rejects.toThrow("PAPERCLIP_DECISION_SIGNING_SECRET must be at least 32 characters");
expect(loadConfigMock).not.toHaveBeenCalled();
});

View File

@ -72,7 +72,7 @@ import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-
import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
import { initTelemetry, getTelemetryClient } from "./telemetry.js";
import { conflict } from "./errors.js";
import { validateDecisionSigningSecret } from "./services/decision-signing.js";
import { ensureDecisionSigningSecret } from "./services/decision-signing.js";
import { createDecisionWakeOriginAgent } from "./services/decision-wakeup.js";
import { coordinateHeartbeatSchedulerShutdown } from "./shutdown.js";
import { systemdNotify } from "./services/systemd-notify.js";
@ -123,7 +123,7 @@ export async function startServer(): Promise<StartedServer> {
// Tracing must be active (or have failed and logged) before the first DB
// connection or the HTTP server exists — see instrumentation.ts.
await instrumentationReady;
validateDecisionSigningSecret();
ensureDecisionSigningSecret();
let config = loadConfig();
initTelemetry({ enabled: config.telemetryEnabled });
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {

View File

@ -1,11 +1,146 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { chmodSync, linkSync, lstatSync, mkdirSync, readFileSync, type Stats, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
const VERSION = "decision-spec-v1";
const MIN_SECRET_LENGTH = 32;
export function validateDecisionSigningSecret() {
const value = process.env.PAPERCLIP_DECISION_SIGNING_SECRET?.trim();
if (!value || value.length < 32) throw new Error("PAPERCLIP_DECISION_SIGNING_SECRET is required and must be at least 32 characters");
return value;
function resolveGeneratedSecretFilePath() {
return path.join(path.dirname(resolveDefaultSecretsKeyFilePath()), "decision-signing.key");
}
function assertOwnedByCurrentUser(stats: Stats, description: string) {
if (process.platform === "win32") return;
const currentUserId = process.getuid?.();
if (currentUserId !== undefined && stats.uid !== currentUserId) {
throw new Error(`${description} must be owned by the Paperclip process user`);
}
}
function enforceKeyFilePermissions(keyPath: string) {
let stats = lstatSync(keyPath);
if (!stats.isFile()) {
throw new Error(`Decision signing key at ${keyPath} must be a regular file`);
}
assertOwnedByCurrentUser(stats, `Decision signing key at ${keyPath}`);
if (process.platform === "win32") return;
const mode = stats.mode & 0o777;
if ((mode & 0o077) !== 0) {
chmodSync(keyPath, 0o600);
stats = lstatSync(keyPath);
if (!stats.isFile()) {
throw new Error(`Decision signing key at ${keyPath} must be a regular file`);
}
assertOwnedByCurrentUser(stats, `Decision signing key at ${keyPath}`);
if ((stats.mode & 0o077) !== 0) {
throw new Error(`Decision signing key at ${keyPath} must have permissions 0600`);
}
}
}
function enforceSecretsDirectoryPermissions(directoryPath: string) {
let stats = lstatSync(directoryPath);
if (!stats.isDirectory()) {
throw new Error(`Decision signing secrets directory at ${directoryPath} must be a directory`);
}
assertOwnedByCurrentUser(stats, `Decision signing secrets directory at ${directoryPath}`);
if (process.platform === "win32") return;
const mode = stats.mode & 0o777;
if ((mode & 0o077) !== 0) {
chmodSync(directoryPath, 0o700);
stats = lstatSync(directoryPath);
if (!stats.isDirectory()) {
throw new Error(`Decision signing secrets directory at ${directoryPath} must be a directory`);
}
assertOwnedByCurrentUser(stats, `Decision signing secrets directory at ${directoryPath}`);
if ((stats.mode & 0o077) !== 0) {
throw new Error(`Decision signing secrets directory at ${directoryPath} must have permissions 0700`);
}
}
}
function readGeneratedSecret(keyPath: string): string {
enforceKeyFilePermissions(keyPath);
const existing = readFileSync(keyPath, "utf8").trim();
if (existing.length < MIN_SECRET_LENGTH) {
throw new Error(
`Invalid decision signing key at ${keyPath} (must be at least ${MIN_SECRET_LENGTH} characters); remove the file to regenerate it or set PAPERCLIP_DECISION_SIGNING_SECRET`,
);
}
return existing;
}
function isAlreadyExists(error: unknown) {
return (error as NodeJS.ErrnoException).code === "EEXIST";
}
function isNotFound(error: unknown) {
return (error as NodeJS.ErrnoException).code === "ENOENT";
}
function loadOrCreateGeneratedSecret(): string {
const keyPath = resolveGeneratedSecretFilePath();
const secretsDirectoryPath = path.dirname(keyPath);
try {
enforceSecretsDirectoryPermissions(secretsDirectoryPath);
return readGeneratedSecret(keyPath);
} catch (error) {
if (!isNotFound(error)) throw error;
}
mkdirSync(secretsDirectoryPath, { recursive: true, mode: 0o700 });
enforceSecretsDirectoryPermissions(secretsDirectoryPath);
const generated = randomBytes(32).toString("base64");
const temporaryPath = `${keyPath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
try {
writeFileSync(temporaryPath, generated, { encoding: "utf8", mode: 0o600, flag: "wx" });
enforceKeyFilePermissions(temporaryPath);
try {
// Publish only a complete key. A hard link is atomic and never replaces
// a key another server process created first.
linkSync(temporaryPath, keyPath);
enforceKeyFilePermissions(keyPath);
return generated;
} catch (error) {
if (!isAlreadyExists(error)) throw error;
return readGeneratedSecret(keyPath);
}
} finally {
try {
unlinkSync(temporaryPath);
} catch (error) {
if (!isNotFound(error)) throw error;
}
}
}
export function resolveDecisionSigningSecret(): string {
const fromEnv = process.env.PAPERCLIP_DECISION_SIGNING_SECRET?.trim();
if (fromEnv) {
if (fromEnv.length < MIN_SECRET_LENGTH) {
throw new Error(
`PAPERCLIP_DECISION_SIGNING_SECRET must be at least ${MIN_SECRET_LENGTH} characters when set (unset it to use an auto-generated key)`,
);
}
return fromEnv;
}
return loadOrCreateGeneratedSecret();
}
/**
* Startup guard: resolves the signing secret once so an invalid explicit
* PAPERCLIP_DECISION_SIGNING_SECRET fails fast and the generated key file is
* materialized before the first decision write. A missing env var is not an
* error the secret is auto-generated and persisted per instance.
*/
export function ensureDecisionSigningSecret() {
resolveDecisionSigningSecret();
}
function canonical(value: unknown): string {
@ -18,7 +153,7 @@ function canonical(value: unknown): string {
}
export function signDecisionSpec(value: unknown) {
return `${VERSION}.${createHmac("sha256", validateDecisionSigningSecret()).update(`${VERSION}:${canonical(value)}`).digest("hex")}`;
return `${VERSION}.${createHmac("sha256", resolveDecisionSigningSecret()).update(`${VERSION}:${canonical(value)}`).digest("hex")}`;
}
export function verifyDecisionSpec(value: unknown, signature: string) {