#3870 · Project path terminal controls
GitHub issue · Base 4db5fee387e8be8dc6dfd511d2cb257bc1f85037
REPRODUCED · Root-cause confidence: high
TL;DR
A project source path containing terminal control bytes passes the production create-request schema unchanged. The production project-list command also emits those bytes unchanged when that path is returned by its API. Two focused safety assertions fail in two clean checkouts of trusted main. This verifies the input validation and CLI output defects without interacting with any real user data; live HTTP creation and database persistence were not exercised.
Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Path validation permits ESC sequences | Verified | Schema safeParse succeeds and preserves the path byte-for-byte. |
| Project listing emits ESC bytes | Verified | Real command with stubbed transport writes ESC to console.log. |
| HTTP creation stores the path and returns 201 | Not run end-to-end | Route statically forwards the parsed source and returns 201; no persistence claim is made from the test. |
| Every control character and downstream automation error is affected | Unverified breadth | Only ESC with SGR sequences was tested; no linked branch was run. |
Environment
macOS Darwin arm64; Node v22.22.3; Corepack pnpm 9.15.0; bb 0.43.1 at the commit above. Two temporary detached worktrees named first and second. No server, provider, port, database, or user runtime was used. Frozen installs succeeded using a temporary pnpm shim to Corepack after the machine’s default launcher failed. Full Turbo build: 58 successful tasks.
Minimal reproduction
- Check out the recorded trusted main commit in a temporary checkout.
- Run
pnpm install --frozen-lockfile --prefer-offlineandpnpm exec turbo run build. - Copy the test artifact to
apps/cli/src/__tests__/command-output/control-path.test.ts. - Run
pnpm exec turbo run test --filter=@bb/cli -- src/__tests__/command-output/control-path.test.ts.
Expected: the request is rejected and terminal controls are absent from human-readable output. Actual, safely escaped:
{"accepted":true,"preserved":true}
{"output":"\nID Name Path\n----------------- ------------ ---------------------------------\nproj-verification Verification /tmp/verification-\u001b[35msample\u001b[0m\n","containsEscape":true}
Test Files 1 failed (1)
Tests 2 failed (2)The two failed assertions are the evidence of the defect, not unexpected infrastructure failures. No terminal screenshot is needed for this byte-level test; terminal rendering itself was not captured.
import { describe, expect, it, vi } from "vitest";
import { createProjectRequestSchema } from "@bb/server-contract";
import { setupCommandOutputTestEnvironment, collectLogPayloads, runCommand, stubServerApi } from "../helpers/command-output-harness.js";
import { registerProjectCommands } from "../../commands/project.js";
const path = `/tmp/verification-${String.fromCharCode(27)}[35msample${String.fromCharCode(27)}[0m`;
describe("project path terminal safety", () => {
setupCommandOutputTestEnvironment();
it("rejects terminal controls at the request boundary", () => {
const parsed = createProjectRequestSchema.safeParse({
name: "Verification",
source: { type: "local_path", hostId: "host-test-001", path },
});
process.stderr.write(JSON.stringify({ accepted: parsed.success, preserved: parsed.success && parsed.data.source.path === path }) + "\n");
expect(parsed.success).toBe(false);
});
it("does not emit terminal controls from a project list response", async () => {
stubServerApi({ "v1.projects.$get": vi.fn(async () => [{
id: "proj-verification", name: "Verification",
sources: [{ type: "local_path", hostId: "host-test-001", path }],
createdAt: 1, updatedAt: 2,
}]) });
await runCommand(["project", "list"], (program) => registerProjectCommands(program, () => "http://server"));
const output = collectLogPayloads(vi.mocked(console.log)).join("\n");
process.stderr.write(JSON.stringify({ output, containsEscape: output.includes(String.fromCharCode(27)) }) + "\n");
expect(output).not.toContain(String.fromCharCode(27));
});
});
Root cause
packages/domain/src/project-path.ts checks path shape and returns null for this input. packages/server-contract/src/api/projects.ts applies trimming and normalization before that validator; neither removes embedded ESC. The create route in apps/server/src/routes/projects.ts forwards the accepted source to project creation. apps/cli/src/commands/project.ts passes the source path directly into the table, and apps/cli/src/table.ts prints the rendered string without a control-character escaping step. The runtime test confirms cli-table3 retains the tested sequence.
Proposed fix
Define the supported project-path character policy and reject terminal control characters at the shared request-validation boundary. Consider escaping human-readable output for already-stored paths as well. Decide how to handle legal POSIX filenames containing controls and existing records. No automated fix or PR was attempted: narrowing accepted project paths changes public request-schema behavior, excluded by this rule’s simple-fix conditions.
Verification
The same agent repeated the test in a second clean detached checkout at the identical base commit, with a separate frozen installation. Both assertions failed again with accepted=true, preserved=true, and containsEscape=true. No ports or data directories were required. The initial test harness imported the command before its transport mock and failed early; import order was corrected, then both final runs reached the intended assertions. This report uses only the corrected runs.
Related issues and PRs
GitHub timeline cross-reference metadata and open-PR search for 3870 found no linked open PR at investigation time. No external issue links or code from PR branches were fetched. Existing security-area classification patterns were reviewed; the verified effect here is narrow terminal-output manipulation, with no demonstrated data loss or broader exploit.
Appendix
First run · Second run. Logs escape ESC and replace local checkout paths. Repository origin/main was explicitly refreshed from get-bb/bb after the local origin was found to point at a fork; both resolved to the recorded commit. Issue content was treated only as untrusted claims; its scripts and instructions were not executed.
AGENT GENERATED