diff --git a/README.md b/README.md index 957c4471e..313dd9639 100644 --- a/README.md +++ b/README.md @@ -495,7 +495,7 @@ actual CLI never drift, so it is always authoritative: | `--source-fallback` | `ZED_PKG_SOURCE_FALLBACK` | on; retry public R2 and GitHub when the HTTP registry is down (`file://` and loopback stay hermetic) | | (env only) | `ZED_PKG_SOURCE_FALLBACK_ALLOW_LOOPBACK` | off; test-org canaries that bind mocks to `127.0.0.1` must set this | | `--home` | `ZED_PKG_HOME` | `~/.zed-pkg` | -| `--token` | `ZED_PKG_TOKEN` | saved credentials | +| (env only; secret) | `ZED_PKG_TOKEN` | saved credentials | | `--auth-url` | `ZED_PKG_AUTH_URL` | `/shared-auth` | | `--supabase-url` | `ZED_PKG_SUPABASE_URL` | optional Supabase project URL | | `--supabase-key` | `ZED_PKG_SUPABASE_KEY` | optional public publishable/anon key | @@ -540,6 +540,14 @@ actual CLI never drift, so it is always authoritative: `--registry file:///path` selects a directory-backed registry: hermetic CI, air-gapped mirrors, and the default `zed r2g` mode all use it. +Secret-bearing values never have a flag, because argv is visible to `ps`, +process accounting, and shell history. The registry bearer token is read only +from `ZED_PKG_TOKEN` (or saved credentials) and the auth password only from +`ZED_PKG_AUTH_PASSWORD`, `--password-stdin`, or the hidden prompt. The removed +spellings `--token` and `--zed-pkg-auth-password` fail closed with exit code 2 +and a redacted error before any dispatch; there is no warn-and-accept window. +Both names are inventoried in `[env].ignore` of every affected `.*cli-flags.toml`. + ## Authentication `zed login` and `zed auth login` are the same operation. With @@ -651,7 +659,8 @@ zed r2g --docker --image node:22-slim # pick an image with the runtime you nee # Certify a disposable Rust registry reached through a port-forward. Publishing # is persistent from the registry's point of view: an identical retry reuses # the immutable version, while changed bytes require a new version or a reset. -zed --registry http://127.0.0.1:48080 --token "$ZED_PKG_TOKEN" \ +# The bearer token comes from the exported ZED_PKG_TOKEN, never from argv. +zed --registry http://127.0.0.1:48080 \ r2g --registry-mode server --clean ``` diff --git a/src/external_subcommands.rs b/src/external_subcommands.rs index 7c793bf26..a4a68aa2a 100644 --- a/src/external_subcommands.rs +++ b/src/external_subcommands.rs @@ -18,10 +18,13 @@ const EXTERNAL_PREFIX: &str = "zed-"; const KNOWN_EXTERNAL_COMMAND: &str = "gitops"; const EXTERNAL_COMMAND_ENV: &str = "ZED_EXTERNAL_SUBCOMMAND"; +/// Root value options lifted into the child environment. Secret-bearing values +/// (`ZED_PKG_TOKEN`, `ZED_PKG_AUTH_PASSWORD`) are deliberately absent: they are +/// environment/session/stdin-only and are inherited from the parent process +/// environment, never translated from argv. const ROOT_VALUE_OPTIONS: &[(&str, &str)] = &[ ("--registry", "ZED_PKG_REGISTRY"), ("--home", "ZED_PKG_HOME"), - ("--token", "ZED_PKG_TOKEN"), ("--auth-url", "ZED_PKG_AUTH_URL"), ("--supabase-url", "ZED_PKG_SUPABASE_URL"), ("--supabase-key", "ZED_PKG_SUPABASE_KEY"), @@ -33,6 +36,20 @@ const ROOT_BOOLEAN_OPTIONS: &[(&str, &str)] = &[ ("--git-submodules", "ZED_PKG_GIT_SUBMODULES"), ]; +/// Removed secret-bearing spellings. Root startup already rejects these; the +/// router independently refuses to build a route so a secret can never reach a +/// child's argv or environment even if that ordering changes. +const REJECTED_SECRET_OPTIONS: &[&str] = &["--token", "--zed-pkg-auth-password"]; + +fn is_rejected_secret_spelling(token: &str) -> bool { + REJECTED_SECRET_OPTIONS.iter().any(|option| { + token == *option + || token + .strip_prefix(option) + .is_some_and(|tail| tail.starts_with('=')) + }) +} + type ExternalEnvironment = Vec<(OsString, OsString)>; type ParsedExternalArguments = (Vec, ExternalEnvironment); @@ -136,7 +153,9 @@ fn external_route(args: &[OsString]) -> Option { while index < args.len() { let token = args[index].to_str()?; - if matches!(token, "--" | "--help" | "-h" | "--version" | "-V") { + if matches!(token, "--" | "--help" | "-h" | "--version" | "-V") + || is_rejected_secret_spelling(token) + { return None; } @@ -226,6 +245,9 @@ fn extract_root_options(args: &[OsString]) -> Option { arguments.extend_from_slice(&args[index..]); break; } + if is_rejected_secret_spelling(token) { + return None; + } if let Some((key, inline)) = root_value_option(token) { let (value, consumed) = match inline { @@ -486,8 +508,8 @@ mod tests { "zed", "gitops", "validate", - "--token", - "fixture-value", + "--registry", + "https://registry.example.invalid", "--offline", "--", "--home", @@ -501,12 +523,48 @@ mod tests { assert_eq!( route.environment, vec![( - OsString::from("ZED_PKG_TOKEN"), - OsString::from("fixture-value") + OsString::from("ZED_PKG_REGISTRY"), + OsString::from("https://registry.example.invalid") )] ); } + #[test] + fn secret_argv_spellings_never_become_a_route_or_child_environment() { + let secret = "SYNTHETIC_ROUTER_SECRET"; + let inline_token = format!("--token={secret}"); + let inline_password = format!("--zed-pkg-auth-password={secret}"); + let cases: [&[&str]; 6] = [ + &["zed", "--token", secret, "gitops", "validate"], + &["zed", inline_token.as_str(), "gitops", "validate"], + &["zed", "gitops", "validate", "--token", secret], + &["zed", "gitops", "validate", inline_token.as_str()], + &[ + "zed", + "--zed-pkg-auth-password", + secret, + "gitops", + "validate", + ], + &["zed", "gitops", "validate", inline_password.as_str()], + ]; + for case in cases { + assert!(external_route(&os_args(case)).is_none(), "routed {case:?}"); + } + assert!( + ROOT_VALUE_OPTIONS + .iter() + .all(|(_, env)| !matches!(*env, "ZED_PKG_TOKEN" | "ZED_PKG_AUTH_PASSWORD")), + "secret-bearing env must never be lifted from argv" + ); + // Child-owned arguments after `--` stay opaque to the router. + let route = external_route(&os_args(&["zed", "gitops", "validate", "--", "--token"])); + assert_eq!( + route.map(|route| (route.arguments, route.environment)), + Some((os_args(&["validate", "--", "--token"]), Vec::new())) + ); + } + #[test] fn malformed_trailing_root_boolean_fails_closed() { assert!( diff --git a/src/graph_export/download.rs b/src/graph_export/download.rs index 53c56f897..a75deed38 100644 --- a/src/graph_export/download.rs +++ b/src/graph_export/download.rs @@ -79,7 +79,7 @@ pub(super) fn graph_url( ); ensure!( url.username().is_empty() && url.password().is_none(), - "registry URL may not embed credentials; use --token or ZED_PKG_TOKEN" + "registry URL may not embed credentials; set ZED_PKG_TOKEN in the environment" ); ensure!( url.query().is_none() && url.fragment().is_none(), diff --git a/src/ops.rs b/src/ops.rs index 54e70399b..cffb7cbba 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -3669,7 +3669,7 @@ pub fn login(cfg: &Config) -> Result<()> { } }; if token.is_empty() { - bail!("no token provided (pass --token, set ZED_PKG_TOKEN, or type one)"); + bail!("no token provided (set ZED_PKG_TOKEN in the environment or type one)"); } let mut credentials = Credentials::load(&cfg.home)?; credentials.set_token(&cfg.registry, token); diff --git a/tests/secret_argv_boundary.rs b/tests/secret_argv_boundary.rs index e359ec56f..10b5f9bab 100644 --- a/tests/secret_argv_boundary.rs +++ b/tests/secret_argv_boundary.rs @@ -181,3 +181,27 @@ fn early_dispatch_does_not_bypass_rejected_secret_argv() { assert_secret_argv_rejected(&args, secret, env, option); } } + +/// User-facing guidance must not advertise the removed secret flags as a +/// supported spelling; rejection tests and the rejection message may name them. +#[test] +fn docs_and_diagnostics_do_not_advertise_removed_secret_flags() { + const SURFACES: &[&str] = &["README.md", "src/ops.rs", "src/graph_export/download.rs"]; + const ADVERTISED: &[&str] = &[ + "| `--token` |", + "--token \"$", + "pass --token", + "use --token", + "--zed-pkg-auth-password \"$", + ]; + for file in SURFACES { + let text = + fs::read_to_string(file).unwrap_or_else(|error| panic!("reading {file}: {error}")); + for pattern in ADVERTISED { + assert!( + !text.contains(pattern), + "{file} still advertises removed secret argv spelling {pattern:?}" + ); + } + } +}