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 Leach 2026-08-13 16:43:21 -07:00 committed by GitHub
parent 05d58cd884
commit 5ca7b4c1fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 899 additions and 472 deletions

View File

@ -493,12 +493,12 @@ Create secrets from environment variables so values do not land in shell history
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID="$(jq -r '.AccessKey.AccessKeyId' /tmp/paperclip-page-uploader-key.json)"
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY="$(jq -r '.AccessKey.SecretAccessKey' /tmp/paperclip-page-uploader-key.json)"
pnpm paperclipai secrets create \
pnpm exec paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-access-key-id \
--value-env PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID
pnpm paperclipai secrets create \
pnpm exec paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-secret-access-key \
--value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
@ -532,7 +532,7 @@ host `AWS_PROFILE` identity for the entire agent run:
Create or update the company skill from this package:
```bash
pnpm paperclipai skills create \
pnpm exec paperclipai skills create \
--company-id <company-id> \
--name "Paperclip Page" \
--slug paperclip-page \
@ -543,7 +543,7 @@ pnpm paperclipai skills create \
Attach it to an agent:
```bash
pnpm paperclipai skills agent sync <agent-id-or-shortname> \
pnpm exec paperclipai skills agent sync <agent-id-or-shortname> \
--company-id <company-id> \
--skill paperclip-page
```

File diff suppressed because it is too large Load Diff

View File

@ -267,7 +267,7 @@ pnpm paperclipai configure --section secrets
Inline secret migration command:
```sh
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id> --apply
# direct database maintenance fallback
pnpm secrets:migrate-inline-env --apply

View File

@ -230,7 +230,7 @@ pnpm dev --authenticated-private
Allow additional private hostnames (for example custom Tailscale hostnames):
```sh
pnpm paperclipai allowed-hostname dotta-macbook-pro
pnpm exec paperclipai allowed-hostname dotta-macbook-pro
```
## Test Commands
@ -537,7 +537,7 @@ Repair an already-created repo-managed worktree and reseed its isolated instance
```sh
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
pnpm paperclipai worktree init --force --seed-mode minimal \
pnpm exec paperclipai worktree init --force --seed-mode minimal \
--name PAP-884-ai-commits-component \
--from-config ~/.paperclip/instances/default/config.json
```
@ -869,15 +869,15 @@ Paperclip CLI now includes client-side control-plane commands in addition to set
Quick examples:
```sh
pnpm paperclipai issue list --company-id <company-id>
pnpm paperclipai issue create --company-id <company-id> --title "Investigate checkout conflict"
pnpm paperclipai issue update <issue-id> --status in_progress --comment "Started triage"
pnpm exec paperclipai issue list --company-id <company-id>
pnpm exec paperclipai issue create --company-id <company-id> --title "Investigate checkout conflict"
pnpm exec paperclipai issue update <issue-id> --status in_progress --comment "Started triage"
```
Set defaults once with context profiles:
```sh
pnpm paperclipai context set --api-base http://localhost:3100 --company-id <company-id>
pnpm exec paperclipai context set --api-base http://localhost:3100 --company-id <company-id>
```
Then run commands without repeating flags:
@ -961,4 +961,4 @@ Networking behavior for this smoke script:
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `pnpm paperclipai allowed-hostname host.docker.internal` and restart Paperclip
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `pnpm exec paperclipai allowed-hostname host.docker.internal` and restart Paperclip

View File

@ -79,9 +79,9 @@ The UI prompt points Hermes at the same machine-readable onboarding endpoints:
For CLI-driven setup, create and inspect the invite directly:
```sh
pnpm paperclipai invite create --company-id <company-id> --payload-json '{"requestType":"agent"}'
pnpm paperclipai invite show <token>
pnpm paperclipai invite onboarding:text <token>
pnpm exec paperclipai invite create --company-id <company-id> --payload-json '{"requestType":"agent"}'
pnpm exec paperclipai invite show <token>
pnpm exec paperclipai invite onboarding:text <token>
```
Hermes should submit a join request with `requestType: "agent"` and
@ -119,14 +119,14 @@ After Hermes submits the join request:
2. Approve it from the board UI, or use:
```sh
pnpm paperclipai join list --company-id <company-id> --status pending_approval
pnpm paperclipai join approve <request-id> --company-id <company-id>
pnpm exec paperclipai join list --company-id <company-id> --status pending_approval
pnpm exec paperclipai join approve <request-id> --company-id <company-id>
```
3. Hermes claims the one-time agent API key:
```sh
pnpm paperclipai join claim-key <request-id> --claim-secret <secret>
pnpm exec paperclipai join claim-key <request-id> --claim-secret <secret>
```
4. Store the claimed Paperclip key in Hermes runtime state or secrets. The claim
@ -182,7 +182,7 @@ Use these entry points depending on who is driving setup:
onboarding prompt.
- Invite API: `GET /api/invites/:token/onboarding.txt` for the generated
llm.txt-style setup instructions.
- CLI invite flow: `pnpm paperclipai invite create`, `invite show`,
- CLI invite flow: `pnpm exec paperclipai invite create`, `invite show`,
`invite onboarding:text`, `join approve`, and `join claim-key`.
- Smoke helpers: `pnpm smoke:hermes-gateway-e2e` for fresh-state Docker
verification and `pnpm smoke:hermes-gateway-join` for an already-running

View File

@ -102,7 +102,7 @@ therefore shadows any Codex login already present inside the sandbox image.
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
```sh
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
pnpm exec paperclipai agent local-cli claudecoder --company-id <company-id>
```
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.

View File

@ -141,7 +141,7 @@ change.
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
```sh
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
pnpm exec paperclipai agent local-cli codexcoder --company-id <company-id>
```
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.

View File

@ -114,7 +114,7 @@ credentials must not be stored in Paperclip `company_secrets`.
The equivalent CLI check is:
```sh
pnpm paperclipai secrets doctor --company-id {companyId}
pnpm exec paperclipai secrets doctor --company-id {companyId}
```
## Provider Vaults
@ -485,7 +485,7 @@ as declarations in the package manifest. Exports omit secret values, secret IDs,
provider references, and encrypted provider material. Use:
```sh
pnpm paperclipai secrets declarations --company-id {companyId}
pnpm exec paperclipai secrets declarations --company-id {companyId}
```
to inspect the declarations that an export would emit before moving a package.

View File

@ -9,39 +9,39 @@ Client-side commands for managing issues, agents, approvals, and more.
```sh
# List issues
pnpm paperclipai issue list [--status todo,in_progress] [--assignee-agent-id <id>] [--match text]
pnpm exec paperclipai issue list [--status todo,in_progress] [--assignee-agent-id <id>] [--match text]
# Get issue details
pnpm paperclipai issue get <issue-id-or-identifier>
pnpm exec paperclipai issue get <issue-id-or-identifier>
# Create issue
pnpm paperclipai issue create --title "..." [--description "..."] [--status todo] [--priority high]
pnpm exec paperclipai issue create --title "..." [--description "..."] [--status todo] [--priority high]
# Update issue
pnpm paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
pnpm exec paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
# Add comment
pnpm paperclipai issue comment <issue-id> --body "..." [--reopen]
pnpm exec paperclipai issue comment <issue-id> --body "..." [--reopen]
# Checkout task
pnpm paperclipai issue checkout <issue-id> --agent-id <agent-id>
pnpm exec paperclipai issue checkout <issue-id> --agent-id <agent-id>
# Release task
pnpm paperclipai issue release <issue-id>
pnpm exec paperclipai issue release <issue-id>
```
## Company Commands
```sh
pnpm paperclipai company list
pnpm paperclipai company get <company-id>
pnpm paperclipai company current [--company-id <company-id>]
pnpm exec paperclipai company list
pnpm exec paperclipai company get <company-id>
pnpm exec paperclipai company current [--company-id <company-id>]
# Export to portable folder package (writes manifest + markdown files)
pnpm paperclipai company export <company-id> --out ./exports/acme --include company,agents
pnpm exec paperclipai company export <company-id> --out ./exports/acme --include company,agents
# Preview import (no writes)
pnpm paperclipai company import \
pnpm exec paperclipai company import \
<owner>/<repo>/<path> \
--target existing \
--company-id <company-id> \
@ -50,7 +50,7 @@ pnpm paperclipai company import \
--dry-run
# Apply import
pnpm paperclipai company import \
pnpm exec paperclipai company import \
./exports/acme \
--target new \
--new-company-name "Acme Imported" \
@ -67,80 +67,80 @@ command.
## Agent Commands
```sh
pnpm paperclipai agent list
pnpm paperclipai agent get <agent-id>
pnpm exec paperclipai agent list
pnpm exec paperclipai agent get <agent-id>
```
## Skills Commands
```sh
# Browse app-shipped catalog skills without changing company state
pnpm paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
pnpm paperclipai skills search "pull request" [--json]
pnpm exec paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
pnpm exec paperclipai skills search "pull request" [--json]
# Inspect catalog metadata and file inventory before install
pnpm paperclipai skills inspect github-pr-workflow
pnpm exec paperclipai skills inspect github-pr-workflow
# Install a catalog skill into the company skill library
# This does not attach the skill to any agent.
pnpm paperclipai skills install github-pr-workflow --company-id <company-id>
pnpm paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
pnpm exec paperclipai skills install github-pr-workflow --company-id <company-id>
pnpm exec paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
# External sources still use import instead of catalog install
pnpm paperclipai skills import ./skills/my-skill --company-id <company-id>
pnpm paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
pnpm exec paperclipai skills import ./skills/my-skill --company-id <company-id>
pnpm exec paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
# Attach desired company skills to an agent after install/import
pnpm paperclipai skills agent sync <agent-id> --skill github-pr-workflow --mode add --company-id <company-id>
pnpm exec paperclipai skills agent sync <agent-id> --skill github-pr-workflow --mode add --company-id <company-id>
```
## Approval Commands
```sh
# List approvals
pnpm paperclipai approval list [--status pending]
pnpm exec paperclipai approval list [--status pending]
# Get approval
pnpm paperclipai approval get <approval-id>
pnpm exec paperclipai approval get <approval-id>
# Create approval
pnpm paperclipai approval create --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>]
pnpm exec paperclipai approval create --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>]
# Approve
pnpm paperclipai approval approve <approval-id> [--decision-note "..."]
pnpm exec paperclipai approval approve <approval-id> [--decision-note "..."]
# Reject
pnpm paperclipai approval reject <approval-id> [--decision-note "..."]
pnpm exec paperclipai approval reject <approval-id> [--decision-note "..."]
# Request revision
pnpm paperclipai approval request-revision <approval-id> [--decision-note "..."]
pnpm exec paperclipai approval request-revision <approval-id> [--decision-note "..."]
# Resubmit
pnpm paperclipai approval resubmit <approval-id> [--payload '{"..."}']
pnpm exec paperclipai approval resubmit <approval-id> [--payload '{"..."}']
# Comment
pnpm paperclipai approval comment <approval-id> --body "..."
pnpm exec paperclipai approval comment <approval-id> --body "..."
```
## Activity Commands
```sh
pnpm paperclipai activity list [--agent-id <id>] [--entity-type issue] [--entity-id <id>]
pnpm exec paperclipai activity list [--agent-id <id>] [--entity-type issue] [--entity-id <id>]
```
## Dashboard
```sh
pnpm paperclipai dashboard get
pnpm exec paperclipai dashboard get
```
## Instance Settings
```sh
pnpm paperclipai instance settings:general
pnpm paperclipai instance settings:general:update --payload-json '{...}'
pnpm paperclipai instance settings:experimental
pnpm paperclipai instance settings:experimental:update --payload-json '{...}'
pnpm exec paperclipai instance settings:general
pnpm exec paperclipai instance settings:general:update --payload-json '{...}'
pnpm exec paperclipai instance settings:experimental
pnpm exec paperclipai instance settings:experimental:update --payload-json '{...}'
```
Experimental features are opt-in and are provided without compatibility guarantees. They may break, change, or be removed at any time. Use them at your own risk.
@ -148,5 +148,5 @@ Experimental features are opt-in and are provided without compatibility guarante
## Heartbeat
```sh
pnpm paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100]
pnpm exec paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100]
```

View File

@ -38,7 +38,7 @@ Store defaults to avoid repeating flags:
```sh
# Set defaults
pnpm paperclipai context set --api-base http://localhost:3100 --company-id <id>
pnpm exec paperclipai context set --api-base http://localhost:3100 --company-id <id>
# View current context
pnpm paperclipai context show
@ -53,18 +53,18 @@ pnpm paperclipai context use default
To avoid storing secrets in context, use an env var:
```sh
pnpm paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
pnpm exec paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
export PAPERCLIP_API_KEY=...
```
Secret operations are available under `paperclipai secrets`:
```sh
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
pnpm paperclipai secrets doctor --company-id <company-id>
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
pnpm exec paperclipai secrets declarations --company-id <company-id> --kind secret
pnpm exec paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
pnpm exec paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
pnpm exec paperclipai secrets doctor --company-id <company-id>
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id> --apply
```
Context is stored at `~/.paperclip/context.json`.

View File

@ -104,7 +104,7 @@ This now includes bind-oriented deployment settings such as `PAPERCLIP_BIND` and
Allow a private hostname for authenticated/private mode:
```sh
pnpm paperclipai allowed-hostname my-tailscale-host
pnpm exec paperclipai allowed-hostname my-tailscale-host
```
## Local Storage Paths

View File

@ -42,7 +42,7 @@ pnpm paperclipai onboard
Allow custom Tailscale hostnames:
```sh
pnpm paperclipai allowed-hostname my-machine
pnpm exec paperclipai allowed-hostname my-machine
```
### `authenticated` + `public`

View File

@ -64,7 +64,7 @@ pnpm dev --authenticated-private
Allow additional private hostnames:
```sh
pnpm paperclipai allowed-hostname dotta-macbook-pro
pnpm exec paperclipai allowed-hostname dotta-macbook-pro
```
For full setup and troubleshooting, see [Tailscale Private Access](/deploy/tailscale-private-access).

View File

@ -203,7 +203,7 @@ Validate secrets config:
```sh
pnpm paperclipai doctor
pnpm paperclipai secrets doctor --company-id <company-id>
pnpm exec paperclipai secrets doctor --company-id <company-id>
```
### Environment Overrides
@ -475,8 +475,8 @@ store.
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
```sh
pnpm paperclipai secrets migrate-inline-env --company-id <company-id>
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id>
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id> --apply
# low-level script for direct database maintenance
pnpm secrets:migrate-inline-env # dry run
@ -493,7 +493,7 @@ Company exports include only environment declarations. They do not include
secret IDs, provider references, encrypted material, or plaintext values.
```sh
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
pnpm exec paperclipai secrets declarations --company-id <company-id> --kind secret
```
Before importing a package into another instance, use those declarations to

View File

@ -58,7 +58,7 @@ http://my-macbook.tailnet.ts.net:3100
If you access Paperclip with a custom private hostname, add it to the allowlist:
```sh
pnpm paperclipai allowed-hostname my-macbook.tailnet.ts.net
pnpm exec paperclipai allowed-hostname my-macbook.tailnet.ts.net
```
## 5. Verify the server is reachable

View File

@ -36,7 +36,7 @@ Shows a color-coded summary: vote counts, per-trace details with reasons, and ex
paperclipai feedback report
# Point to a different server or company
pnpm paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
pnpm exec paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
# Include raw payload dumps in the report
pnpm paperclipai feedback report --payloads
@ -112,7 +112,7 @@ Exports are full by default. `traces/` keeps the Paperclip envelope, while `full
```bash
# Custom server and output directory
pnpm paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
pnpm exec paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
```
### Reading an exported trace

View File

@ -23,7 +23,7 @@ The CLI exposes the same surface:
```sh
pnpm paperclipai instance settings:experimental
pnpm paperclipai instance settings:experimental:update --payload-json '{...}'
pnpm exec paperclipai instance settings:experimental:update --payload-json '{...}'
```
Those commands change the same opt-in settings that the UI manages.

View File

@ -82,7 +82,7 @@ PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join
- If Paperclip rejects the container-visible host with a hostname error, allow it from host:
```bash
pnpm paperclipai allowed-hostname host.docker.internal
pnpm exec paperclipai allowed-hostname host.docker.internal
```
Then restart Paperclip and rerun the smoke script.
@ -90,7 +90,7 @@ Then restart Paperclip and rerun the smoke script.
- Authenticated/private mode: ensure hostnames are in the allowed list when required:
```bash
pnpm paperclipai allowed-hostname <host>
pnpm exec paperclipai allowed-hostname <host>
```
## Prerequisites

View File

@ -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([]);
});
});

View File

@ -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");
});
});

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.

View File

@ -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 {

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.
`);
}

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.");

View File

@ -23,6 +23,8 @@ Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes.
Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity.
**CLI safety — use `pnpm exec paperclipai` for content-bearing arguments.** When you run the Paperclip CLI, use `pnpm exec paperclipai` for any argument that can hold untrusted content. Untrusted content includes issue text, comment bodies, Markdown, pasted snippets, and model output. `pnpm exec` runs the installed binary directly and passes the argument as an inert `argv` value. Do not use `pnpm paperclipai` for such an argument. The safe form and the unsafe form differ only by the `exec` keyword, so read the command with care. `pnpm paperclipai` is a `package.json` script; `pnpm` runs the argument through `/bin/sh` first. The shell then interprets a backtick pair, `$( )`, `$NAME`, and `; | & < >` before the CLI starts. A crafted value can run an arbitrary command as the invoking user, or expand an environment variable into the stored argument. `npx paperclipai` is also injection-safe; use it when no local install is present. See `doc/CLI.md` for the full safe/unsafe matrix.
**Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability.
## The Heartbeat Procedure

View File

@ -18,13 +18,13 @@ export function buildAgentOnboardingPrompt(input: AgentOnboardingPromptInput) {
? `No candidate URLs are available. Ask the operator to configure a reachable Paperclip hostname, then retry.
Suggested steps for the operator:
- choose a hostname that resolves to the Paperclip host from your runtime
- run: pnpm paperclipai allowed-hostname <host>
- run: pnpm exec paperclipai allowed-hostname <host>
- restart Paperclip
- verify with: curl -fsS http://<host>:3100/api/health
- regenerate this agent onboarding prompt`
: `If none are reachable, ask the operator to add a reachable Paperclip hostname, restart, and retry.
Suggested command for the operator:
- pnpm paperclipai allowed-hostname <host>
- pnpm exec paperclipai allowed-hostname <host>
Then verify with: curl -fsS <base-url>/api/health`;
const resolutionLine = resolutionTestUrl

View File

@ -484,7 +484,7 @@ function generateReadmeFromSelection(
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.");