Repository navigation
fix(cli): stop echoing value-bearing --project and --target arguments - #318
Merged
Merged
Conversation
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
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to #308, which made
hack envandhack hostrefuse value-bearing arguments without echoing them. Two echoes were left:--projecton other commands. The command slugged the argument and printed the slug inUnknown project "<slug>". For example,--project x=secretprintedUnknown project "xsecret".--targetonup/down/restart. The refusal printedInvalid --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). HoldsparseProjectSelectorOption, moved here fromenv-arguments.ts, plusunknownProjectMessageandresolveRegisteredProjectOption.=, or any character outsidePROJECT_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 isUnknown project. Run 'hack init' ....resolveRegisteredProjectOption:project.ts(up/down/restart/ps/run/exec/logs/open),branch.ts,config.ts,tui.ts,project-admin.tsandenv.ts.runClicheck.runClinow parses--projectfor every command that declaresoptProject, before the handler runs, in the same way as the shared--envcheck. This covers the filter commands (status,usage,projects,projects prune,host ps) and the unsupportedremote/gatewaycommands too.native-compose-selection.tsandnative-compose-adoption-command.tsuse the same parser.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:No projects matched the --project filter.;--jsonreports"filter": nullwith"filter_redacted": true(the field used to carry the slug, e.g."filter": "sk-live-secret-9z"for--project Sk_Live_Secret_9z);/v1/projectsechoes itsfilterquery back. The slug never leaves the process.--targetonup/down/restart. The fixed message is nowInvalid --target value. Expected one of: auto, local, remote., thrown as aCliUsageError. Each handler checks--targetfirst, before project discovery or runtime work.My_App,my_app,MY-APPandmy appall selectmy-app.docs/cli.mddocuments the--projectecho rule andfilter_redacted.Validation
tests/project-target-argument-redaction-cli.test.tsruns the real CLI in subprocesses. Isolation: a temp HOME/HACK_HOME/global config, an isolatedDOCKER_CONFIG, a PATH that holds onlygit, 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 thangitstarted.--project: 3 values (x=…,….x,…:x) against every command path that declaresoptProjectinCLI_SPEC. The list is derived and guarded witharrayContaining.--project: checked on 15 project-resolving commands. The test also confirms that a canonical name is still printed.status,projects,projects prune --dry-run, and their--jsonvariants (plusprojects --json --summary) never echo a non-canonical unmatched filter. The JSON assertions checkfilter: null,filter_redacted: true, the canonical name for a canonical filter, andmy-appfor the registered aliasMy_App.filterlike 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.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.tsnow expects aCliUsageErrorwith the exact fixed message.tests/env-key-argument-redaction-cli.test.tsimports from the new module.runClicheck--target--targetlateprojects.ts; sending the redacted filter to the daemon; emitting a non-null JSONfilterwhen redacted; treating only canonical input as nameable; treating every filter as nameable.bun teston 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-pruneandcli-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
remoteandgateway(unsupported) keep their ownsanitizeProjectSlugresolvers. The newrunClicheck refuses value-bearing arguments for them, but a well-formed non-canonical name such asMy_Appis still printed in slug form in their "Unknown project" message.usageandhost psare covered for value-bearing arguments only. A well-formed filter makes them read the process table withps, so the test does not run that case.hack x supervisor ... --project) parses--projectitself, outsideCLI_SPECand therunClicheck. Its resolver would printUnknown project "<slug>", and its "Extension disabled" hint echoes the raw argv. This is the same onnextand needs a follow-up issue.bun run testand hosted CI were not run locally.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.