#3870 · Project path terminal controls

Bug · Priority Low · Effort Low · cli, security · 2026-09-17

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

ClaimFindingEvidence
Path validation permits ESC sequencesVerifiedSchema safeParse succeeds and preserves the path byte-for-byte.
Project listing emits ESC bytesVerifiedReal command with stubbed transport writes ESC to console.log.
HTTP creation stores the path and returns 201Not run end-to-endRoute statically forwards the parsed source and returns 201; no persistence claim is made from the test.
Every control character and downstream automation error is affectedUnverified breadthOnly 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

  1. Check out the recorded trusted main commit in a temporary checkout.
  2. Run pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build.
  3. Copy the test artifact to apps/cli/src/__tests__/command-output/control-path.test.ts.
  4. 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