Skip to content

fix(cli): stop echoing value-bearing --project and --target arguments - #318

Merged
roodboi merged 7 commits into
nextfrom
wf/d2-arg-echo-b12
Oct 13, 2026
Merged

roodboi merged 7 commits into
nextfrom
wf/d2-arg-echo-b12

Conversation

@roodboi

@roodboi roodboi commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #308, which made hack env and hack host refuse value-bearing arguments without echoing them. Two echoes were left:

  1. --project on other commands. The command slugged the argument and printed the slug in Unknown project "<slug>". For example, --project x=secret printed Unknown project "xsecret".
  2. --target on up/down/restart. The refusal printed Invalid --target value: <argument>. It also happened late, after project discovery and the Docker check.

Policy: a value is never echoed, logged, passed to a child argv or written into a file name. Well-formed identifiers can still be echoed.

Changes

  • src/cli/project-selector.ts (new). Holds parseProjectSelectorOption, moved here from env-arguments.ts, plus unknownProjectMessage and resolveRegisteredProjectOption.
    • An argument that contains =, or any character outside PROJECT_SELECTOR_PATTERN, is refused with fixed text before the registry is read.
    • Unknown project "<name>" names the project only when it was typed in canonical form. Otherwise the message is Unknown project. Run 'hack init' ....
  • Shared resolver. These resolvers now use resolveRegisteredProjectOption: project.ts (up/down/restart/ps/run/exec/logs/open), branch.ts, config.ts, tui.ts, project-admin.ts and env.ts.
  • runCli check. runCli now parses --project for every command that declares optProject, before the handler runs, in the same way as the shared --env check. This covers the filter commands (status, usage, projects, projects prune, host ps) and the unsupported remote/gateway commands too.
  • Native Compose selectors. native-compose-selection.ts and native-compose-adoption-command.ts use the same parser.
  • Project filters. status/projects (src/commands/projects.ts, resolveListingFilter) name the filter only when it was typed canonically or selects a registered project, whose name the listing shows anyway. Otherwise:
    • the panel prints No projects matched the --project filter.;
    • --json reports "filter": null with "filter_redacted": true (the field used to carry the slug, e.g. "filter": "sk-live-secret-9z" for --project Sk_Live_Secret_9z);
    • the daemon is skipped and the filter is matched in process, because hackd's /v1/projects echoes its filter query back. The slug never leaves the process.
  • --target on up/down/restart. The fixed message is now Invalid --target value. Expected one of: auto, local, remote., thrown as a CliUsageError. Each handler checks --target first, before project discovery or runtime work.
  • Legacy alias spellings that already worked still work: My_App, my_app, MY-APP and my app all select my-app.
  • Docs. docs/cli.md documents the --project echo rule and filter_redacted.

Validation

  • New tests/project-target-argument-redaction-cli.test.ts runs the real CLI in subprocesses. Isolation: a temp HOME/HACK_HOME/global config, an isolated DOCKER_CONFIG, a PATH that holds only git, and the spawn-argv recorder. Each case checks that the canary, including its case-folded/slugged form, is absent from stdout, stderr, child argv/env and file names, and that no child other than git started.
    • Value-bearing --project: 3 values (x=…, ….x, …:x) against every command path that declares optProject in CLI_SPEC. The list is derived and guarded with arrayContaining.
    • Unregistered non-canonical --project: checked on 15 project-resolving commands. The test also confirms that a canonical name is still printed.
    • Listing filters: status, projects, projects prune --dry-run, and their --json variants (plus projects --json --summary) never echo a non-canonical unmatched filter. The JSON assertions check filter: null, filter_redacted: true, the canonical name for a canonical filter, and my-app for the registered alias My_App.
    • Daemon query: a fake hackd on a Unix socket (same version, echoes filter like the real payload) proves that an unnameable filter never reaches /v1/projects. Canonical and registered-alias filters still reach it, so the check is not vacuous.
    • Legacy aliases: the alias spellings of a registered project still resolve (config get).
    • --target: up/down/restart --target x=… and --target <Alias> are refused in an empty cwd with the fixed message.
  • tests/project-execution-routing.test.ts now expects a CliUsageError with the exact fixed message. tests/env-key-argument-redaction-cli.test.ts imports from the new module.
  • Mutation testing: 7 mutations, all caught by the new tests:
    • removing the runCli check
    • always naming the unknown project
    • skipping the selector pattern
    • echoing --target
    • checking --target late
    • echoing a slugged listing filter
    • refusing alias spellings
  • Rework mutations (each caught, then reverted): restoring the previous projects.ts; sending the redacted filter to the daemon; emitting a non-null JSON filter when redacted; treating only canonical input as nameable; treating every filter as nameable.
  • Checks run:
    • bun test on the new test file, project-execution-routing, env-key-argument-redaction-cli, integration-ownership, config-command, native-compose-selection, experimental-gating, native-config-command, mcp-inspect-tools, projects-command, projects-registry, projects-prune and cli-command: all pass.
    • bun run --cwd packages/cli typecheck: clean.
    • bun run --cwd packages/cli check: 0 errors. The 34 warnings come from files this PR does not change.
    • bun run docs:cli-reference: no diff, because the CLI surface is unchanged.

Remaining gaps

  • remote and gateway (unsupported) keep their own sanitizeProjectSlug resolvers. The new runCli check refuses value-bearing arguments for them, but a well-formed non-canonical name such as My_App is still printed in slug form in their "Unknown project" message.
  • usage and host ps are covered for value-bearing arguments only. A well-formed filter makes them read the process table with ps, so the test does not run that case.
  • The unsupported supervisor extension (hack x supervisor ... --project) parses --project itself, outside CLI_SPEC and the runCli check. Its resolver would print Unknown project "<slug>", and its "Extension disabled" hint echoes the raw argv. This is the same on next and needs a follow-up issue.
  • Full bun run test and hosted CI were not run locally.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

roodboi and others added 6 commits October 12, 2026 18:07
Follow-up to #308. A --project or --target argument is sometimes a
mistyped KEY=value or a value pasted into the wrong position, so its
refusal must never contain the argument or a slug derived from it.

- Move parseProjectSelectorOption to src/cli/project-selector.ts and add
  resolveRegisteredProjectOption. project, branch, config, tui, project
  owner and env/host resolvers share it: an argument with "=" or a
  character outside the selector pattern is refused with fixed text
  before any registry read, and "Unknown project" names the project
  only when it was typed in canonical form.
- runCli parses --project for every command declaring optProject before
  the handler runs (including filters, host ps, remote and gateway).
- status/projects name an unmatched filter only when canonical; native
  Compose selectors use the same parser.
- up/down/restart validate --target first, with a fixed message listing
  auto, local, remote.

Legacy alias spellings (My_App, "my app") still select my-app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`hack projects --json --project Sk_Live_Secret_9z` (and `status --json`)
printed `"filter": "sk-live-secret-9z"`, and the slug was also sent to
hackd's /v1/projects query, which echoes it back. That broke the rule that
a slug derived from a non-canonical argument is never echoed.

- resolveListingFilter splits the filter into the in-process match name
  and a label that may be shown: the name when typed canonically or when
  it selects a registered project (the listing shows that name anyway).
- Otherwise JSON reports "filter": null with "filter_redacted": true, the
  empty-result panel uses fixed text, and the daemon is skipped so the
  slug never leaves the process.
- Tests: --json variants of status/projects/prune in the listing-filter
  test, the registered-alias label, and a fake hackd on a Unix socket that
  proves an unnameable filter never reaches /v1/projects while canonical
  and registered filters still do.
- docs/cli.md documents the --project echo rule and filter_redacted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests that spawn the source CLI built the child env from
`{ ...process.env, ... }`, so the contributor's HOME and inherited
HACK_*, DOCKER_* and XDG_* variables reached the child CLI. Some had no
HACK_HOME either and used the real ~/.hack.

Add tests/helpers/private-cli-env.ts: privateCliEnv() gives the child a
0700 HOME inside the fixture, inherits only PATH and TMPDIR, and adds the
isolated Docker client config. Migrate every source-CLI spawn under
tests/ that spread process.env, plus the three harnesses fixed in #313.

Add tests/cli-spawn-env-isolation.test.ts, which scans tests/ and fails
when a file that spawns index.ts spreads process.env without a private
HOME. Exceptions are listed with a reason and an exact count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migrate the CLI spawns the first pass missed: the project-name fixture
env (used by project-name-command and the local-network-hint tests),
dispatch-e2e, the MCP contract server env, and the spawns that passed no
env at all (doctor-browser-network, log-pipe, mcp-entrypoint and the
host-exec-tty Python probes).

Rebuild the guard on the TypeScript syntax tree. It now follows a CLI
file's test-local imports and flags any whole copy of the runner env
(spread, direct assignment, Bun.env, Object.assign, rest destructuring),
a HOME derived from the runner's home, a CLI spawn without env, and an
env literal without HOME. Its limits are documented in the scan module.

privateCliEnv now sets mode 0700 on a HOME that already exists, and
native-project-down keeps the private HOME out of the project checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
host-exec-lifetime built its CLI env without HOME, so the CLI resolved
the runner's real home. It now uses privateCliEnv with the same PATH,
HACK_HOME and HACK_NO_INTERACTIVE values.

lifecycle-process-delivery ran the client CLI through shell run, which
lays env over the runner's whole process.env. New privateCliRunEnv
returns the private env plus unsetEnvKeys for every other runner key,
so the child sees only the private env. LEAKED_PARENT still reaches the
client and must still be absent from the launched command.

The guard now follows same-file function returns for env and argv,
treats src/lib/shell.ts run/exec and same-file wrappers of them as
spawns, and adds shell-runner-env for a shell CLI spawn without
unsetEnvKeys. Object.keys of the env counts as a key-name use.
Reverting either fix fails the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicted files: none. The merge applied cleanly (D1 changes only tests/
and tests/helpers/; D2 changes src/, docs/cli.md and its own tests).

Semantic check: D2's new CLI-spawning tests
(project-target-argument-redaction-cli, env-key-argument-redaction-cli)
already pass explicit env objects with HOME, so D1's
cli-spawn-env-isolation guard passes on the combined tree without edits.
Regenerated plugins and docs/reference/cli.md: no changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi changed the base branch from next to wf/d1-test-env-isolation-b12 October 13, 2026 01:33
roodboi added a commit that referenced this pull request Oct 13, 2026
Stack PR #320 on PR #318.

Conflicted files: none. The two branches change disjoint file sets, so
git merged every file automatically. Generated surfaces (plugin bundle,
CLI reference, checked-in renders) were regenerated after the merge and
showed no drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from wf/d1-test-env-isolation-b12 to next October 13, 2026 02:16
Re-parents onto next after the previous stacked PR squashed in; next's tree equals 6aeec7e, which this branch already contains, so the branch tree is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 13, 2026 02:17
@roodboi
roodboi merged commit 09676b0 into next Oct 13, 2026
30 of 31 checks passed
@roodboi
roodboi deleted the wf/d2-arg-echo-b12 branch October 13, 2026 02:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant