fix(security): standardize paperclipai CLI guidance on safe npx path (#11343)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip provides CLI guidance to agents and operators through
documentation and runtime messages.
> - Content-bearing `pnpm paperclipai` examples send arguments through a
shell.
> - Shell evaluation can execute command substitutions in untrusted
argument content.
> - Runtime hostname guidance can also place request-derived content
inside a shell command.
> - This pull request uses `npx paperclipai` for content-bearing
guidance and uses a static hostname placeholder.
> - The benefit is safer copy-paste guidance for agents and operators.

## Linked Issues or Issue Description

**Issue type**
Incorrect information

**Where is the issue?**
CLI guidance in `doc/CLI.md`, `skills/paperclip/SKILL.md`,
documentation, and runtime-generated hints.

**What's wrong?**
Content-bearing `pnpm paperclipai` commands can pass argument text
through `/bin/sh`. Shell command substitution in an argument can execute
before the CLI receives the value.

**Suggested fix**
Use `npx paperclipai` for content-bearing commands. Use a static
`<host>` placeholder when runtime guidance displays the allowed-hostname
command.

## What Changed

- Replace content-bearing `pnpm paperclipai` examples with `npx
paperclipai` across the documentation and agent-facing guidance.
- Update runtime-generated CLI hints to use a static `<host>`
placeholder.
- Add safety notes to `doc/CLI.md` and `skills/paperclip/SKILL.md`.
- Add scans and regression tests for unsafe invocation and hostile
hostname headers.
- Keep fixed lifecycle commands and `pnpm --filter @paperclipai/*` build
commands unchanged.

## Verification

- Run `tsc --noEmit` for the changed server files.
- Run `cli-invocation-safety.test.ts`.
- Run `private-hostname-guard.test.ts`.
- Confirm that hostile hostname headers do not enter shown shell
commands.
- Confirm that the three commits contain the required Paperclip
co-author trailer.

## Risks

- This change updates documentation and diagnostic text across many
surfaces.
- Fixed lifecycle and setup commands remain unchanged.
- The tests fail if content-bearing `pnpm paperclipai` guidance returns.
- The change does not alter the CLI argument parser.

## Model Used

OpenAI Codex, GPT-5, tool use, code execution, and repository review
assistance.

## 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:
Nicky LeachandPaperclip authored and GitHub committed 2026-08-13 16:43:21 -07:00
1 parent 05d58cd884
commit 5ca7b4c1fe
27 files changed
+899 -472

No files matched your search

@@ -0,0 +1,345 @@
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { generateReadme } from "../services/company-export-readme.js";
// The Paperclip CLI is unsafe when an operator or agent runs it through
// `pnpm paperclipai <sub> <arg>` with a content-bearing argument. `pnpm` treats
// `paperclipai` as a `package.json` script and wraps the argument in a
// double-quoted `/bin/sh` string. The shell then runs command substitution (a
// backtick pair or `$( )`) and variable expansion (`$NAME`) before the CLI
// starts. `pnpm exec paperclipai` runs the installed binary directly. It passes
// the argument as an inert argv value and does not run a shell. The safe form
// and the unsafe form differ only by the `exec` keyword.
//
// This guard has two parts. First, it asserts that the runtime surfaces which
// build a CLI instruction from a non-fixed value emit the safe `pnpm exec` form.
// Second, it scans the guidance surfaces across the repository for any
// content-bearing `pnpm paperclipai` example that returned to the docs.
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../..");
function read(relPath: string): string {
return readFileSync(path.join(repoRoot, relPath), "utf8");
}
// ── Content-bearing detection ─────────────────────────────────────────────
//
// A `pnpm paperclipai` line is content-bearing when it carries an argument that
// an agent or operator can fill from untrusted or semi-trusted content: an
// issue body, a comment, Markdown, a pasted snippet, model output, a hostname
// from a request header, an import URL, an identifier, or a secret reference.
// Flags that carry free text, an identifier, a payload, a file, or a secret.
const CONTENT_FLAGS = [
"--body",
"--body-file",
"--title",
"--comment",
"--message",
"--description",
"--reason",
"--goal",
"--alt",
"--color",
"--content",
"--content-file",
"--summary",
"--note",
"--text",
"--name",
"--slug",
"--payload",
"--payload-json",
"--env-json",
"--company-id",
"--agent-id",
"--claim-secret",
"--api-key-env-var-name",
"--out",
"--file",
];
// Subcommands whose value is a hostname or an import source. A request header or
// an external URL can supply that value, so it is never fixed.
const CONTENT_SUBCOMMANDS = ["allowed-hostname", "company import"];
// Placeholders that name an untrusted value type. A bare local placeholder such
// as `<name>` or `<plugin-id>` on a local lifecycle command is not listed here.
const UNTRUSTED_PLACEHOLDERS = [
"<token>",
"<secret>",
"<request-id>",
"this-github-url-or-folder",
];
// A note or warning line names the unsafe form on purpose. Skip it so the
// security note itself does not trip the scan.
function isNoteLine(line: string): boolean {
const lower = line.toLowerCase();
return (
line.includes("pnpm exec paperclipai") ||
line.includes("npx paperclipai") ||
lower.includes("do not use") ||
lower.includes("acceptable only")
);
}
function isContentBearing(line: string): boolean {
const commandStart = line.indexOf("pnpm paperclipai");
if (commandStart < 0) return false;
if (isNoteLine(line)) return false;
// Inspect only the command tail. Wrapping code before the command, such as a
// `${pc.dim(...)}` template call in TypeScript, is not part of the argument.
const command = line.slice(commandStart);
if (command.includes("${")) return true;
if (command.includes("url-or-folder>") || command.toLowerCase().includes("<url")) return true;
if (UNTRUSTED_PLACEHOLDERS.some((token) => command.includes(token))) return true;
if (CONTENT_SUBCOMMANDS.some((sub) => command.includes(sub))) return true;
return CONTENT_FLAGS.some(
(flag) => command.includes(`${flag} `) || command.includes(`${flag}=`),
);
}
// ── Repository walk ───────────────────────────────────────────────────────
//
// The scan covers guidance the reader follows now: documentation, skills, and
// the runtime source that emits CLI instructions. It skips historical records
// and internal automation, because a reader does not copy a command from them:
// `doc/logs` holds past verification logs, `doc/plans` holds dated design
// plans, and `scripts` holds trusted automation with fixed arguments. It skips
// test files, because a test names the unsafe form to assert against it.
const SKIP_DIRS = new Set([
"node_modules",
".git",
"dist",
"build",
".next",
"coverage",
".paperclip",
"tmp",
]);
const SKIP_PATH_PREFIXES = ["doc/logs/", "doc/plans/", "scripts/"];
const SCAN_EXTENSIONS = new Set([
".md",
".mdx",
".ts",
".tsx",
".js",
".jsx",
".sh",
".json",
]);
function isTestFile(relPath: string): boolean {
return (
relPath.includes("__tests__/") ||
/\.(test|spec)\.[tj]sx?$/.test(relPath)
);
}
function listGuidanceFiles(): string[] {
const found: string[] = [];
function walk(absDir: string, relDir: string): void {
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
if (entry.isSymbolicLink()) continue;
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(path.join(absDir, entry.name), relPath);
continue;
}
if (!SCAN_EXTENSIONS.has(path.extname(entry.name))) continue;
if (isTestFile(relPath)) continue;
if (SKIP_PATH_PREFIXES.some((prefix) => relPath.startsWith(prefix))) continue;
found.push(relPath);
}
}
walk(repoRoot, "");
return found;
}
// ── Backslash line continuation ───────────────────────────────────────────
//
// A shell reads a backslash at the end of a line as a line join. So one
// command can spread its content-bearing arguments across many physical
// lines. The scan must see the whole command, not one physical line. If it
// checks each physical line alone, a `pnpm paperclipai` command whose unsafe
// argument sits on a later line passes undetected.
//
// `toLogicalLines` joins each backslash-continued physical line to the next
// one. It returns the joined text and the line number of the first physical
// line, so an offender report still points to the start of the command.
interface LogicalLine {
text: string;
lineNumber: number;
}
function toLogicalLines(source: string): LogicalLine[] {
const physicalLines = source.split("\n");
const logicalLines: LogicalLine[] = [];
let buffer: string | null = null;
let startLine = 0;
physicalLines.forEach((physicalLine, index) => {
const continues = /\\\s*$/.test(physicalLine);
const body = physicalLine.replace(/\\\s*$/, "");
if (buffer === null) {
startLine = index + 1;
buffer = body;
} else {
buffer += body;
}
if (!continues) {
logicalLines.push({ text: buffer, lineNumber: startLine });
buffer = null;
}
});
if (buffer !== null) {
logicalLines.push({ text: buffer, lineNumber: startLine });
}
return logicalLines;
}
function scanText(relPath: string, source: string): string[] {
const offenders: string[] = [];
for (const { text, lineNumber } of toLogicalLines(source)) {
if (isContentBearing(text)) {
offenders.push(`${relPath}:${lineNumber}: ${text.trim()}`);
}
}
return offenders;
}
function scanForOffenders(): string[] {
const offenders: string[] = [];
for (const relPath of listGuidanceFiles()) {
offenders.push(...scanText(relPath, read(relPath)));
}
return offenders;
}
describe("paperclipai CLI invocation safety", () => {
it("keeps content-bearing pnpm paperclipai examples out of every guidance surface", () => {
const offenders = scanForOffenders();
expect(
offenders,
`Use \`pnpm exec paperclipai\` for content-bearing arguments:\n${offenders.join("\n")}`,
).toEqual([]);
});
// ── Direct assertions on the runtime-generated instruction surfaces ──────
it("emits a static, non-interpolated safe form from the private-hostname guard messages", () => {
const source = read("server/src/middleware/private-hostname-guard.ts");
// The blocked-host and missing-host messages must never interpolate the
// request Host header into the guidance command. An operator or an agent
// can paste the guidance into a shell, and that outer shell evaluates a
// metacharacter span in the host before any CLI receives argv. `pnpm exec`
// does not stop the outer shell. Emit a static `<host>` placeholder only.
expect(source).toContain("run pnpm exec paperclipai allowed-hostname <host>");
expect(source).not.toContain("allowed-hostname ${hostname}");
expect(source).not.toContain("pnpm paperclipai allowed-hostname");
});
it("emits a static, non-interpolated safe form from the onboarding access diagnostics", () => {
const source = read("server/src/routes/access.ts");
expect(source).not.toMatch(/pnpm paperclipai allowed-hostname/);
expect(source).toContain("pnpm exec paperclipai allowed-hostname <host>");
// The onboarding host comes from the request base URL, so a requester
// controls it. The emitted command must carry a static `<host>` placeholder
// and never interpolate that value.
expect(source).not.toMatch(/allowed-hostname \$\{/);
});
it("emits the safe form from the agent onboarding prompt", () => {
const source = read("ui/src/lib/agent-onboarding-prompt.ts");
expect(source).not.toContain("pnpm paperclipai allowed-hostname");
expect(source).toContain("pnpm exec paperclipai allowed-hostname <host>");
});
it("emits the safe form in the generated company-export README", () => {
const readme = generateReadme(
{ agents: [], projects: [], skills: [], issues: [] } as never,
{ companyName: "Acme", companyDescription: null },
);
expect(readme).toContain("pnpm exec paperclipai company import this-github-url-or-folder");
expect(readme).not.toContain("pnpm paperclipai company import");
});
it("emits the safe form in the company-export preview builder", () => {
const source = read("ui/src/pages/CompanyExport.tsx");
expect(source).not.toContain("pnpm paperclipai company import");
expect(source).toContain("pnpm exec paperclipai company import");
});
// ── The safe-invocation note ─────────────────────────────────────────────
it("documents the safe form in doc/CLI.md", () => {
const cli = read("doc/CLI.md");
expect(cli).toContain("Security: safe invocation for content-bearing arguments");
expect(cli).toContain("pnpm exec paperclipai");
expect(cli).toContain("inert `argv`");
});
it("documents the safe form in the agent-facing skill", () => {
const skill = read("skills/paperclip/SKILL.md");
expect(skill).toContain("CLI safety");
expect(skill).toContain("pnpm exec paperclipai");
expect(skill).toContain("Do not use `pnpm paperclipai`");
});
// ── Backslash line continuation ──────────────────────────────────────────
it("flags a content-bearing pnpm paperclipai command split across continued lines", () => {
const source = [
"```sh",
"pnpm paperclipai issue create \\",
' --company-id <company-id> \\',
' --title "$(cat /etc/passwd)"',
"```",
].join("\n");
const offenders = scanText("doc/EXAMPLE.md", source);
expect(offenders).toHaveLength(1);
// The report points to the first physical line of the command.
expect(offenders[0]).toContain("doc/EXAMPLE.md:2:");
expect(offenders[0]).toContain("--title");
});
it("flags a continued command whose only content-bearing flag sits on the last line", () => {
const source = [
"pnpm paperclipai worktree init \\",
" --force \\",
" --name PAP-000-example",
].join("\n");
const offenders = scanText("doc/EXAMPLE.md", source);
expect(offenders).toHaveLength(1);
expect(offenders[0]).toContain("doc/EXAMPLE.md:1:");
expect(offenders[0]).toContain("--name");
});
it("does not flag a continued npx paperclipai command", () => {
const source = [
"npx paperclipai issue create \\",
" --company-id <company-id> \\",
' --title "Investigate checkout conflict"',
].join("\n");
expect(scanText("doc/EXAMPLE.md", source)).toEqual([]);
});
it("does not flag a continued pnpm paperclipai command without content-bearing arguments", () => {
const source = [
"pnpm paperclipai worktree reseed \\",
" --from current \\",
" --seed-mode full",
].join("\n");
expect(scanText("doc/EXAMPLE.md", source)).toEqual([]);
});
});
@@ -42,14 +42,17 @@ describe("privateHostnameGuard", () => {
expect(res.status).toBe(200);
});
it("blocks unknown hostnames with remediation command", async () => {
it("blocks unknown hostnames with a static remediation command", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
expect(res.status).toBe(403);
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
// The remediation command carries a static `<host>` placeholder. It never
// interpolates the request Host header into the command.
expect(res.body?.error).toContain("run pnpm exec paperclipai allowed-hostname <host>");
expect(res.body?.error).not.toContain(unknownHostname);
});
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
it("blocks unknown hostnames on page routes with a static plain-text remediation command", async () => {
const middleware = privateHostnameGuard({
enabled: true,
allowedHostnames: ["some-other-host"],
@@ -73,7 +76,24 @@ describe("privateHostnameGuard", () => {
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith(
expect.stringContaining(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`),
expect.stringContaining("run pnpm exec paperclipai allowed-hostname <host>"),
);
expect(res.send).not.toHaveBeenCalledWith(expect.stringContaining(unknownHostname));
}, 20_000);
it("does not reflect a hostile Host header into the remediation command", async () => {
// An unauthenticated requester can send an invalid Host header that holds
// shell metacharacters. `extractHostname` falls back to the raw header when
// URL parsing fails. The 403 guidance must not echo that value, so an
// operator or an agent cannot paste an attacker-controlled span into a
// shell. Use a harmless, nonexistent command name inside the span.
const hostileHost = "evil$(echo marker)host";
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/api/health").set("Host", hostileHost);
expect(res.status).toBe(403);
expect(res.body?.error).toContain("run pnpm exec paperclipai allowed-hostname <host>");
expect(res.body?.error).not.toContain("evil");
expect(res.body?.error).not.toContain("$(");
expect(res.body?.error).not.toContain("marker");
});
});
+1 -1
View File
@@ -47,7 +47,7 @@ Network examples:
- Local loopback on one host: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:8642"; agentDefaultsPayload.paperclipApiUrl = "http://127.0.0.1:3100".
- Local dashboard root or chat URL on one host: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:9119" or "http://127.0.0.1:9119/chat"; Paperclip maps it to "http://127.0.0.1:9119/api".
- LAN/private network: agentDefaultsPayload.apiBaseUrl = "http://192.168.1.25:8642"; agentDefaultsPayload.paperclipApiUrl = "http://192.168.1.10:3100". Use private IPs or hostnames reachable from both machines.
- Private overlay: agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642"; agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100". Add the Paperclip hostname with pnpm paperclipai allowed-hostname <host> when authenticated/private mode requires it.
- Private overlay: agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642"; agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100". Add the Paperclip hostname with pnpm exec paperclipai allowed-hostname <host> when authenticated/private mode requires it.
- Docker: if Hermes runs on the host and Paperclip runs in Docker, use agentDefaultsPayload.apiBaseUrl = "http://host.docker.internal:8642". If Hermes runs in another container, use the Compose service DNS name such as "http://hermes:8642".
- Reverse proxy/TLS: publish Hermes behind HTTPS and set agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example"; set agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example". Keep API_SERVER_KEY required at the origin or proxy.
@@ -42,12 +42,16 @@ export function resolvePrivateHostnameAllowSet(opts: { allowedHostnames: string[
return allowSet;
}
function blockedHostnameMessage(hostname: string): string {
return (
`Hostname '${hostname}' is not allowed for this Paperclip instance. ` +
`If you want to allow this hostname, please run pnpm paperclipai allowed-hostname ${hostname}`
);
}
// The hostname comes from the request Host header, so an unauthenticated
// requester controls it. Never put that value into the guidance command. An
// operator or an agent can paste the guidance into a shell, and that outer
// shell evaluates a backtick, `$( )`, or `$NAME` span in the host before any
// CLI receives argv. A direct-exec form such as `pnpm exec` does not stop the
// outer shell. Emit a static `<host>` placeholder and do not echo the raw request
// value. The operator supplies the real hostname.
const BLOCKED_HOSTNAME_MESSAGE =
"This hostname is not allowed for this Paperclip instance. " +
"If you want to allow a hostname, run pnpm exec paperclipai allowed-hostname <host>.";
export function privateHostnameGuard(opts: {
enabled: boolean;
@@ -68,7 +72,7 @@ export function privateHostnameGuard(opts: {
const wantsJson = req.path.startsWith("/api") || req.accepts(["json", "html", "text"]) === "json";
if (!hostname) {
const error = "Missing Host header. If you want to allow a hostname, run pnpm paperclipai allowed-hostname <host>.";
const error = "Missing Host header. If you want to allow a hostname, run pnpm exec paperclipai allowed-hostname <host>.";
if (wantsJson) {
res.status(403).json({ error });
} else {
@@ -82,7 +86,7 @@ export function privateHostnameGuard(opts: {
return;
}
const error = blockedHostnameMessage(hostname);
const error = BLOCKED_HOSTNAME_MESSAGE;
if (wantsJson) {
res.status(403).json({ error });
} else {
+9 -3
View File
@@ -1642,7 +1642,13 @@ function buildOnboardingDiscoveryDiagnostics(input: {
code: "openclaw_onboarding_private_host_not_allowed",
level: "warn",
message: `Onboarding host "${apiHost}" is not in allowed hostnames for authenticated/private mode.`,
hint: `Run pnpm paperclipai allowed-hostname ${apiHost}`
// `apiHost` comes from the request base URL, so a requester controls it.
// Never put that value into the guidance command. An operator or an agent
// can paste the command into a shell, and that outer shell evaluates a
// metacharacter span in the host before any CLI receives argv. A
// direct-exec form such as `pnpm exec` does not stop the outer shell. Emit
// a static `<host>` placeholder and keep the raw host in the message only.
hint: `Run pnpm exec paperclipai allowed-hostname <host>`
});
}
@@ -1776,7 +1782,7 @@ function buildInviteOnboardingManifest(
guidance:
opts.deploymentMode === "authenticated" &&
opts.deploymentExposure === "private"
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm exec paperclipai allowed-hostname <host>`."
: "Ensure OpenClaw can reach this Paperclip API base URL for invite, claim, and skill bootstrap calls."
},
textInstructions: {
@@ -1999,7 +2005,7 @@ export function buildInviteOnboardingTextDocument(
If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration.
For authenticated/private mode, they may need:
- pnpm paperclipai allowed-hostname <host>
- pnpm exec paperclipai allowed-hostname <host>
- then restart Paperclip and retry onboarding.
`);
}
+1 -1
View File
@@ -157,7 +157,7 @@ export function generateReadme(
lines.push("## Getting Started");
lines.push("");
lines.push("```bash");
lines.push("pnpm paperclipai company import this-github-url-or-folder");
lines.push("pnpm exec paperclipai company import this-github-url-or-folder");
lines.push("```");
lines.push("");
lines.push("See [Paperclip](https://paperclip.ing) for more information.");