Files
PaperClipAI/cli/src/__tests__/env-lab.test.ts
T
Nicky LeachandPaperclip fdb9a4880d fix(security): route paperclipai CLI guidance through safe npx form (CWE-78) (#11400)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip provides CLI commands and guidance for operators and
agents
> - The `pnpm paperclipai` script can pass argument values through a
shell
> - Shell re-parsing can execute command substitutions inside quoted
values
> - This pull request routes guidance through inert-argv `npx
paperclipai` commands and adds regression coverage
> - The benefit is safer operator guidance across documentation and
runtime hints

## Linked Issues or Issue Description

This pull request fixes a command-injection-class defect in Paperclip
CLI guidance.

**What happened?**

The `pnpm paperclipai <sub> --flag "$VALUE"` form can re-parse argument
values through a shell. A command substitution inside a quoted value can
execute on the host.

**Expected behavior**

Paperclip guidance must pass CLI values as inert argument values.
Host-derived values must not appear in copyable commands.

**Steps to reproduce**

1. Run a Paperclip guidance command that uses the `pnpm paperclipai`
script.
2. Provide a quoted value that contains a command substitution.
3. Observe that the shell can evaluate the substitution before the CLI
starts.
4. Compare the result with the `npx paperclipai` form.

**Paperclip version or commit**

`5670984b75d109950c968542a0111ebb6967f4da`

**Deployment mode**

All deployment modes that show or use the affected CLI guidance.

**Installation method**

Built from source and installed CLI guidance.

**Agent adapter(s) involved**

Not adapter-specific (core bug).

**Database mode**

Not database-related.

**Access context**

Both.

**Additional context**

The earlier merged PR
[#11343](https://github.com/paperclipai/paperclip/pull/11343) used the
unsafe `pnpm exec paperclipai` form. This fresh PR replaces that
guidance with the safe `npx paperclipai` form.

## What Changed

- Standardize documentation and runtime hints on `npx paperclipai`.
- Remove the broken `pnpm exec paperclipai` guidance.
- Use a static `<host>` placeholder in private-hostname guidance.
- Add regression tests for unsafe forms, continued lines, static hosts,
and offline guidance.

## Verification

- `git diff --check
origin/master...origin/fix/paperclipai-cli-npx-safe-invocation` passes.
- The branch adds `server/src/__tests__/cli-invocation-safety.test.ts`
and updates private-hostname tests.
- CI must run the new tests, typecheck, lint, and build checks.
- Local Vitest execution was not available because this worktree has no
installed Vitest binary.

## Risks

- The change affects operator and agent documentation text.
- The runtime hints now show `<host>` instead of a request-derived host
value.
- No database schema or migration changes exist.
- CI will detect any missed unsafe invocation or type error.

## Model Used

OpenAI GPT-5, exact model ID `gpt-5`, with tool use and code-review
assistance. The model used repository inspection, Git operations, and PR
preparation.

## 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] CI ran the test suites and they pass; local test execution was
unavailable in this worktree
- [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 addressed all Greptile and reviewer comments before requesting
merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-14 22:11:16 -07:00

288 lines
11 KiB
TypeScript

import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as p from "@clack/prompts";
import {
buildEnvLabCleanupCommand,
collectEnvLabDoctorStatus,
envLabDoctorCommand,
resolveEnvLabCliInvocation,
resolveEnvLabSshStatePath,
} from "../commands/env-lab.js";
describe("env-lab command", () => {
it("resolves the default SSH fixture state path under the instance root", () => {
const statePath = resolveEnvLabSshStatePath("fixture-test");
expect(statePath).toContain(
path.join("instances", "fixture-test", "env-lab", "ssh-fixture", "state.json"),
);
});
it("reports doctor status for an instance without a running fixture", async () => {
const status = await collectEnvLabDoctorStatus({ instance: "fixture-test-missing" });
expect(status.statePath).toContain(
path.join("instances", "fixture-test-missing", "env-lab", "ssh-fixture", "state.json"),
);
expect(typeof status.ssh.supported).toBe("boolean");
expect(status.ssh.running).toBe(false);
expect(status.ssh.environment).toBeNull();
});
});
describe("env-lab cleanup command hint", () => {
const originalCwd = process.cwd();
afterEach(() => {
process.chdir(originalCwd);
});
// Resolve a source-checkout invocation for a fabricated checkout root. A source
// checkout runs this module from `<root>/src/commands/env-lab.ts`, so the
// resolver reads that layout and returns the tsx runner and source entry.
function sourceInvocation(root: string) {
return resolveEnvLabCliInvocation(path.join(root, "src", "commands", "env-lab.ts"));
}
// Resolve a bundled-build invocation for a fabricated package root. The bundled
// build runs this module from `<root>/dist/index.js`, so the resolver returns
// that file as the entry with no tsx runner.
function bundledInvocation(root: string) {
return resolveEnvLabCliInvocation(path.join(root, "dist", "index.js"));
}
// Split a command that uses POSIX single-quoting into its argument tokens. The
// parser reads a single-quoted span verbatim and reads `\'` outside a span as a
// literal single quote. This is the same rule a POSIX shell obeys, so a token
// list proves the shell reads the exact paths and runs no embedded command.
function tokenizePosix(command: string): string[] {
const tokens: string[] = [];
let current = "";
let started = false;
let inQuotes = false;
for (let index = 0; index < command.length; index += 1) {
const character = command[index];
if (inQuotes) {
if (character === "'") {
inQuotes = false;
} else {
current += character;
}
} else if (character === "'") {
inQuotes = true;
started = true;
} else if (character === "\\") {
index += 1;
current += command[index];
started = true;
} else if (character === " ") {
if (started) {
tokens.push(current);
current = "";
started = false;
}
} else {
current += character;
started = true;
}
}
if (started) {
tokens.push(current);
}
return tokens;
}
// Return the two path arguments from the `node` command.
function extractPaths(command: string): string[] {
const tokens = tokenizePosix(command);
return tokens.slice(1, 3);
}
it("resolves both CLI paths to absolute paths", () => {
const command = buildEnvLabCleanupCommand();
const paths = extractPaths(command);
expect(paths).toHaveLength(2);
for (const resolved of paths) {
expect(path.isAbsolute(resolved)).toBe(true);
}
expect(command.endsWith("env-lab down")).toBe(true);
});
it("points at the checked-out tsx runner and cli source entry", () => {
const [tsxBin, entry] = extractPaths(buildEnvLabCleanupCommand());
expect(tsxBin).toContain(
path.join("cli", "node_modules", "tsx", "dist", "cli.mjs"),
);
expect(entry).toContain(path.join("cli", "src", "index.ts"));
});
it("returns the same command from a checkout subdirectory", () => {
const fromRoot = buildEnvLabCleanupCommand();
// Simulate a contributor who runs `env-lab doctor` from a subdirectory of
// the checkout. A relative path would change with the working directory, so
// this asserts the command stays constant.
process.chdir(path.dirname(originalCwd));
const fromParent = buildEnvLabCleanupCommand();
process.chdir(originalCwd);
expect(fromParent).toBe(fromRoot);
});
it("never restores the unsafe pnpm invocation forms", () => {
const command = buildEnvLabCleanupCommand();
// The bare `pnpm paperclipai` script form is unsafe. The `pnpm exec` form
// does not resolve the CLI binary. Keep both out of the hint.
expect(command).not.toContain("pnpm paperclipai");
expect(command).not.toContain("pnpm exec paperclipai");
});
// A checkout path can hold shell metacharacters. A contributor copies the hint
// and pastes it into a shell. The hint must neutralize each metacharacter, so
// the shell reads the exact path and runs no embedded command. Each case below
// is a checkout root with one dangerous construct.
const dangerousRoots = [
{ label: "a dollar sign", root: "/tmp/env$lab/checkout" },
{ label: "command substitution", root: "/tmp/$(touch pwned)/checkout" },
{ label: "backticks", root: "/tmp/`touch pwned`/checkout" },
{ label: "a double quote", root: '/tmp/env"lab/checkout' },
{ label: "a single quote", root: "/tmp/env'lab/checkout" },
];
for (const { label, root } of dangerousRoots) {
it(`keeps a checkout path with ${label} inert in the cleanup hint`, () => {
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation(root) });
const tokens = tokenizePosix(command);
const tsxBin = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
const entry = path.join(root, "src", "index.ts");
// The shell reads the exact paths as single argument tokens. It does not
// split the paths or run the embedded construct.
expect(tokens).toEqual(["node", tsxBin, entry, "env-lab", "down"]);
// The old double-quoted form left `$(...)`, a backtick pair, and `$NAME`
// live. Do not restore it.
expect(command).not.toContain(`"${tsxBin}"`);
expect(command).not.toContain(`"${entry}"`);
});
}
it("forwards the inspected instance to the cleanup hint", () => {
const command = buildEnvLabCleanupCommand({
instance: "fixture-test",
invocation: sourceInvocation("/tmp/checkout"),
});
const tokens = tokenizePosix(command);
// The hint ends with `--instance <id>`, so it stops the fixture the doctor
// command diagnosed, not the default instance.
expect(tokens.slice(-2)).toEqual(["--instance", "fixture-test"]);
});
it("omits the instance flag when the doctor command uses the default instance", () => {
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation("/tmp/checkout") });
// Without a selected instance, `env-lab down` resolves the same default
// instance the doctor command inspected. Do not add an empty flag.
expect(command).not.toContain("--instance");
expect(command.endsWith("env-lab down")).toBe(true);
});
it("keeps an instance id with shell metacharacters inert", () => {
const command = buildEnvLabCleanupCommand({
instance: "$(touch pwned)",
invocation: sourceInvocation("/tmp/checkout"),
});
const tokens = tokenizePosix(command);
// The shell reads the instance id as one literal token and runs no embedded
// command.
expect(tokens.slice(-2)).toEqual(["--instance", "$(touch pwned)"]);
expect(command).not.toContain('"$(touch pwned)"');
});
it("runs the bundled dist entry directly, without the tsx runner", () => {
const command = buildEnvLabCleanupCommand({
invocation: bundledInvocation("/opt/pkg"),
});
const tokens = tokenizePosix(command);
// The published package ships one `dist/index.js` file and no tsx runner, so
// node runs that file directly.
expect(tokens).toEqual(["node", path.join("/opt/pkg", "dist", "index.js"), "env-lab", "down"]);
expect(command).not.toContain("tsx");
expect(command).not.toContain(path.join("src", "index.ts"));
});
it("keeps a bundled package path with shell metacharacters inert", () => {
const root = "/opt/$(touch pwned)/pkg";
const command = buildEnvLabCleanupCommand({ invocation: bundledInvocation(root) });
const tokens = tokenizePosix(command);
const entry = path.join(root, "dist", "index.js");
// The shell reads the exact bundled path as one token and runs no embedded
// command.
expect(tokens).toEqual(["node", entry, "env-lab", "down"]);
expect(command).not.toContain(`"${entry}"`);
});
});
describe("env-lab doctor cleanup hint instance", () => {
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
afterEach(() => {
if (originalInstanceId === undefined) {
delete process.env.PAPERCLIP_INSTANCE_ID;
} else {
process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
}
vi.restoreAllMocks();
});
// Capture the cleanup hint the doctor prints. The doctor reports through
// `p.log`, so the test replaces each channel and reads the captured lines.
function captureDoctorMessages(): string[] {
const messages: string[] = [];
vi.spyOn(p.log, "message").mockImplementation((message?: string) => {
messages.push(message ?? "");
});
vi.spyOn(p.log, "success").mockImplementation(() => {});
vi.spyOn(p.log, "warn").mockImplementation(() => {});
vi.spyOn(p.log, "info").mockImplementation(() => {});
return messages;
}
it("pins the PAPERCLIP_INSTANCE_ID instance when opts.instance is absent", async () => {
// The doctor diagnoses the instance that `PAPERCLIP_INSTANCE_ID` selects.
// The cleanup hint must target that instance, not the default instance.
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
const messages = captureDoctorMessages();
await envLabDoctorCommand({ instance: undefined });
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
expect(cleanup).toBeDefined();
expect(cleanup).toContain("env-lab down");
expect(cleanup).toContain("--instance");
expect(cleanup).toContain("env-selected-instance");
});
it("pins the explicit instance over PAPERCLIP_INSTANCE_ID", async () => {
// An explicit `--instance` flag overrides the environment variable, so the
// hint targets the explicit instance the doctor inspected.
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
const messages = captureDoctorMessages();
await envLabDoctorCommand({ instance: "explicit-instance" });
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
expect(cleanup).toBeDefined();
expect(cleanup).toContain("--instance");
expect(cleanup).toContain("explicit-instance");
expect(cleanup).not.toContain("env-selected-instance");
});
});