diff --git a/cwmscli/commands/env.py b/cwmscli/commands/env.py index 477e2a2..37226e5 100644 --- a/cwmscli/commands/env.py +++ b/cwmscli/commands/env.py @@ -7,6 +7,7 @@ import click +from cwmscli.utils import colors from cwmscli.utils.env_store import ( ENV_DEFAULTS, EnvStoreError, @@ -21,6 +22,7 @@ from cwmscli.utils.ssl_errors import is_cert_verify_error, ssl_help_text SENSITIVE_KEYS = {"CDA_API_KEY"} +MANAGED_ENV_KEYS = ("CDA_API_ROOT", "CDA_API_KEY", "OFFICE", "ENVIRONMENT") def _stdout_is_tty() -> bool: @@ -115,6 +117,30 @@ def _check_env(env_config: Dict[str, str]) -> Dict: return {"reachable": True, "latency_ms": latency_ms, "auth": "ok", "error": None} +def _active_env_mismatches( + env_name: str, env_config: Dict[str, str] +) -> Dict[str, tuple]: + """Return configured values that differ from the current process environment.""" + expected = {key: env_config[key] for key in MANAGED_ENV_KEYS if key in env_config} + expected.setdefault("ENVIRONMENT", env_name) + return { + key: (value, os.environ.get(key)) + for key, value in expected.items() + if value != os.environ.get(key) + } + + +def _format_env_mismatch(key: str, expected: str, actual: Optional[str]) -> str: + """Describe an environment mismatch without exposing sensitive values.""" + if key in SENSITIVE_KEYS: + if actual is None: + return f" {key}: configured, but not set in the current shell" + return f" {key}: current value does not match the configured value" + if actual is None: + return f" {key}: expected {expected!r}, but it is not set" + return f" {key}: expected {expected!r}, found {actual!r}" + + @click.group("env", help="Manage CDA environments and API keys") def env_group(): """Environment management commands for cwms-cli.""" @@ -195,9 +221,36 @@ def show_cmd(check: bool): current_env = os.environ.get("ENVIRONMENT") if current_env: - click.echo( - f"Current environment: {click.style(current_env, fg='green', bold=True)}\n" - ) + active_config = load_env(current_env) + if active_config is None: + click.echo(f"Current environment: {colors.warn(current_env)}") + click.echo( + colors.warn( + f"Warning: ENVIRONMENT references '{current_env}', but that " + "environment is not configured." + ) + ) + else: + mismatches = _active_env_mismatches(current_env, active_config) + if mismatches: + click.echo( + f"Current environment: " + f"{colors.warn(f'{current_env} (values differ)')}" + ) + click.echo( + colors.warn( + f"Warning: current shell values do not match environment " + f"'{current_env}':" + ) + ) + for key, (expected, actual) in mismatches.items(): + click.echo(_format_env_mismatch(key, expected, actual)) + click.echo( + "Shell startup configuration may have overridden these values." + ) + else: + click.echo(f"Current environment: {colors.ok(current_env)}") + click.echo() else: click.echo("No environment currently active\n") @@ -310,9 +363,9 @@ def _detect_shell_kind() -> str: return "bash" -def _export_help_lines(env_name: str) -> str: - """Per-shell instructions for loading an env into the current shell.""" - recipes = { +def _export_recipes(env_name: str) -> Dict[str, str]: + """Return per-shell commands for loading an env into the current shell.""" + return { "bash": f'eval "$(cwms-cli env export {env_name} --format bash)"', "zsh": f'eval "$(cwms-cli env export {env_name} --format bash)"', "powershell": ( @@ -325,6 +378,11 @@ def _export_help_lines(env_name: str) -> str: ), "fish": f"cwms-cli env export {env_name} --format fish | source", } + + +def _export_help_lines(env_name: str) -> str: + """Per-shell instructions for loading an env into the current shell.""" + recipes = _export_recipes(env_name) detected = _detect_shell_kind() primary = recipes.get(detected, recipes["bash"]) @@ -349,19 +407,50 @@ def _export_help_lines(env_name: str) -> str: return "\n".join(lines) +def _startup_warning_lines(env_name: str, shell_kind: str) -> str: + """Explain how shell startup configuration can replace activated values.""" + startup_config = { + "bash": "startup files such as .bashrc", + "zsh": "startup files such as .zshrc", + "fish": "startup files such as config.fish", + "powershell": "PowerShell profiles", + "cmd": "cmd.exe AutoRun commands", + }.get(shell_kind, "shell startup configuration") + recipes = _export_recipes(env_name) + recipe = recipes.get(shell_kind, recipes["bash"]) + + return "\n".join( + [ + colors.warn( + f"Warning: {startup_config} may override CDA_API_ROOT, " + "CDA_API_KEY, OFFICE, or ENVIRONMENT." + ), + "After the shell opens, verify the environment and CDA connection with:", + " cwms-cli env show --check", + f"If those values do not match '{env_name}' after startup, reapply with:", + f" {recipe}", + ] + ) + + def spawn_shell_with_env(env_vars: Dict[str, str], env_name: str): """Spawn a new shell with environment variables set.""" user_shell = _detect_shell() + shell_kind = _detect_shell_kind() new_env = os.environ.copy() new_env.update(env_vars) click.echo( - f"Activating environment: {click.style(env_name, fg='green', bold=True)}", + f"Activating environment: {colors.ok(env_name)}", err=True, ) click.echo(f"Shell: {user_shell}", err=True) + click.echo(_startup_warning_lines(env_name, shell_kind), err=True) + exit_hint = "Type 'exit' to return to your original environment" + if shell_kind in {"bash", "zsh", "fish"}: + exit_hint += " (or press Ctrl+D)" click.echo( - "Type 'exit' or press Ctrl+D to return to your original environment\n", + f"\n{exit_hint}\n", err=True, ) @@ -373,7 +462,7 @@ def spawn_shell_with_env(env_vars: Dict[str, str], env_name: str): sys.exit(1) -@env_group.command("activate", help="Activate an environment in a new shell") +@env_group.command("activate", short_help="Activate an environment in a new shell") @click.argument("env_name") def activate_cmd(env_name: str): """ @@ -382,10 +471,22 @@ def activate_cmd(env_name: str): The environment variables will be set in the new shell and persist until you exit the shell. Type 'exit' to return to your original environment. - Note: This spawns a child shell. Your parent shell, and any IDE - already open, will not see these variables. To populate the current - shell, use: eval "$(cwms-cli env export --format bash)" + Note: This spawns a child shell. Your parent shell and any IDE + already open will not see these variables. Shell startup files can + also replace inherited values such as CDA_API_ROOT. This is common + in Solaris profiles. + To set the values after shell initialization, use: + + \b + eval "$(cwms-cli env export --format bash)" + + Verify the activated values, connectivity, and authentication with: + + \b + cwms-cli env show --check + + \b Examples: cwms-cli env activate prod cwms-cli env activate localhost @@ -461,7 +562,7 @@ def _format_env(env_vars: Dict[str, str], fmt: str) -> str: "-o", type=click.Path(dir_okay=False, writable=True, resolve_path=True), default=None, - help="Write to FILE (mode 0600) instead of standard output.", + help="Write to FILE instead of standard output (mode 0600 on POSIX).", ) @click.option( "--no-key", @@ -535,7 +636,8 @@ def export_cmd( except OSError as e: click.echo(f"Error writing {path}: {e}", err=True) sys.exit(1) - click.echo(f"Wrote {path} (0600)", err=True) + permission_note = " (0600)" if sys.platform != "win32" else "" + click.echo(f"Wrote {path}{permission_note}", err=True) if path.endswith(".env") or os.path.basename(path).startswith(".env"): click.echo("Reminder: add this file to .gitignore.", err=True) return diff --git a/docs/cli/env.rst b/docs/cli/env.rst index 70d8641..9ad23d8 100644 --- a/docs/cli/env.rst +++ b/docs/cli/env.rst @@ -2,14 +2,15 @@ Environment Manager =================== Manage named CDA environments with ``cwms-cli env``. Each environment stores -a CDA API root URL, office code, and optional API key in a JSON file under -``~/.config/cwms-cli/envs/`` (or ``$XDG_CONFIG_HOME/cwms-cli/envs/`` when that -variable is set), on all platforms. Files are created with mode ``0600`` -(owner-only read/write) so only your user account can read them. +its name and CDA API root URL, plus an optional office code and API key, in a +JSON file under ``~/.config/cwms-cli/envs/`` (or +``$XDG_CONFIG_HOME/cwms-cli/envs/`` when that variable is set), on all +platforms. Files use mode ``0600`` on POSIX. On Windows, ``cwms-cli`` attempts +to restrict the file ACL to the current user. -This keeps API keys out of project directories, shell history, and command -lines, and lets you reference environments by name instead of juggling -URLs and credentials. +This keeps saved API keys out of project directories and lets you reference +environments by name instead of repeatedly putting URLs and credentials on +command lines. Built-in Environments @@ -137,6 +138,63 @@ Create or update an environment configuration. letting you attach an office and API key. All other environment names require ``--api-root``. +.. warning:: + + Supplying ``--api-key`` can place the key in shell history and make it + briefly visible in process arguments during setup. Apply your shell's + history controls when entering sensitive values. Once saved, later + commands use the environment name instead of placing the key on their + command lines. + +On-premises server setup and COOP default environments (optional) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Configure named environments for the primary on-premises server and the COOP +server with their respective CDA API roots, API keys, and offices: + +.. code-block:: bash + + cwms-cli env setup onprem \ + --api-root https://example.usace.army.mil/XXX-data/ \ + --api-key YOUR_KEY \ + --office XXX + + cwms-cli env setup coop \ + --api-root https://example.coop.usace.army.mil/XXX-data/ \ + --api-key YOUR_KEY \ + --office XXX + +On the primary on-premises server, place the following at the **bottom of +your** ``~/.bashrc`` **file** to load ``onprem`` by default: + +.. code-block:: bash + + # Load on-premises defaults unless `cwms-cli env activate` supplied another environment. + if [ -z "${ENVIRONMENT:-}" ]; then + eval "$(cwms-cli --quiet env export onprem --format bash)" + fi + +On the COOP server, use the same guarded block at the **bottom of +``~/.bashrc``**, but load ``coop`` by default: + +.. code-block:: bash + + # Load COOP defaults unless `cwms-cli env activate` supplied another environment. + if [ -z "${ENVIRONMENT:-}" ]; then + eval "$(cwms-cli --quiet env export coop --format bash)" + fi + +Each block loads ``CDA_API_ROOT``, ``CDA_API_KEY``, ``OFFICE``, and +``ENVIRONMENT`` from its corresponding environment. Before adding the block, +remove any existing lines in ``~/.bashrc`` that +assign or export ``CDA_API_ROOT``, ``CDA_API_KEY``, ``OFFICE``, or +``ENVIRONMENT``. Those assignments would override values inherited from +``cwms-cli env activate ``. Keep the new block at the bottom of the file +and after any setup that adds ``cwms-cli`` to ``PATH``. The ``ENVIRONMENT`` +guard preserves any environment selected with ``env activate``. The +``--quiet`` option suppresses routine log messages during shell startup while +still displaying warnings and errors. + cwms-cli env show ~~~~~~~~~~~~~~~~~ @@ -167,13 +225,21 @@ The API key is always redacted — only ``has API key`` or ``no API key`` is sho On a fresh install (before any ``env setup``), ``prod`` appears with ``(built-in)`` and shows ``Office: not set``. -The ``*`` marks the currently active environment (from the ``ENVIRONMENT`` -variable). +The ``*`` marks the environment selected by the ``ENVIRONMENT`` variable. +``env show`` also compares that environment's configured ``CDA_API_ROOT``, +``CDA_API_KEY``, ``OFFICE``, and ``ENVIRONMENT`` values with the current +shell, whether or not ``--check`` is used. If an assigned value differs or is +missing, the command displays a warning and identifies the affected variables. +API key values remain redacted. This can reveal when shell startup +configuration replaced values supplied by ``env activate``. **Options:** -- ``--check`` — test connectivity and API key validity for each environment - (requires network access). Adds ``Connect`` and ``Auth`` lines to the output. +- ``--check`` — after the live-shell comparison, test the saved API root and + API key for each configured environment (requires network access). Adds + ``Connect`` and ``Auth`` lines to the output. If the live shell differs from + the saved environment, the mismatch warning remains authoritative for the + activated shell. .. code-block:: bash @@ -216,12 +282,13 @@ Export an environment's variables to your current shell or a file. **Safety:** The API key is never printed to a terminal by default. If stdout is a TTY and the environment has an API key, ``export`` shows shell-specific recipes instead. Use ``--show-key`` to override, or ``--output FILE`` to write -directly to disk (recommended — guarantees ``0600`` permissions and no -scrollback exposure). +directly to disk without scrollback exposure. Output files use mode ``0600`` +on POSIX; on Windows, they inherit the destination directory's ACL. **Options:** -- ``--output FILE`` — write to a file with ``0600`` permissions instead of stdout. +- ``--output FILE`` — write to a file instead of stdout (mode ``0600`` on + POSIX). - ``--no-key`` — omit ``CDA_API_KEY`` (useful for templates or sharing). - ``--show-key`` — allow the API key to be displayed in the terminal. @@ -235,8 +302,40 @@ Activate an environment in a new shell session. cwms-cli env activate prod -This spawns a child shell with the environment variables set. Type ``exit`` -or press ``Ctrl+D`` to return to your original shell. +This spawns a child shell with the environment variables set. Type ``exit`` to +return to your original shell; in Unix-like shells, you can also press +``Ctrl+D``. Before opening the shell, ``activate`` warns that startup +configuration may replace the selected values and prints a shell-specific +command that reapplies them after startup. Once the child shell opens, verify +the live values, CDA connectivity, and authentication: + +.. code-block:: bash + + cwms-cli env show --check + +.. warning:: + + ``activate`` passes the configured variables to the child shell before that + shell initializes. Startup configuration such as ``.bash_profile``, + ``.bashrc``, PowerShell profiles, or ``cmd.exe`` AutoRun commands can then + replace inherited values. For example, if startup configuration + unconditionally sets ``CDA_API_ROOT``, ``CDA_API_KEY``, ``OFFICE``, or + ``ENVIRONMENT``, that value takes precedence over the selected cwms-cli + environment. This is a common configuration on Solaris systems, but the + same limitation applies on any platform. + + ``cwms-cli env show --check`` first reports whether the selected + environment's configured values match the child shell. It then checks the + saved configurations' connectivity and authentication. If it reports a + live-value mismatch, use the shell-specific reapply command printed by + ``activate``. For bash or zsh, that command is: + + .. code-block:: bash + + eval "$(cwms-cli env export --format bash)" + + This changes the child shell after its startup files have run. The values + remain set until they are changed, unset, or the child shell exits. .. note:: @@ -268,19 +367,22 @@ Storage and Security (respects ``XDG_CONFIG_HOME`` when set) **File permissions:** ``0600`` on POSIX (owner-only read/write). On Windows, -an ACL restricts access to the current user. +``cwms-cli`` attempts to restrict the ACL to the current user. **Security model:** The user account is the security boundary, matching ``aws``, ``gcloud``, ``kubectl``, and ``gh``. This feature defends against: - Accidental ``git add`` of a key — files live in ``~/.config/``, not the repo - Key pasted into an LLM — users share ``env show`` output (always redacted) -- Key visible in ``ps`` or shell history — users reference the env name, not values +- Repeated key exposure in ``ps`` or shell history — after setup, users + reference the environment name rather than the key - Key in terminal scrollback — ``export`` refuses TTY output by default This feature does **not** defend against root access or same-user process -reads. For encrypted-at-rest storage, use a vault (1Password CLI, HashiCorp -Vault, AWS Secrets Manager) and feed values in via environment variables. +reads. The initial ``env setup --api-key`` invocation can also be recorded in +shell history or process arguments. For encrypted-at-rest storage, use a vault +(1Password CLI, HashiCorp Vault, AWS Secrets Manager) and feed values in via +environment variables. Headless and CI Usage diff --git a/tests/commands/test_env.py b/tests/commands/test_env.py index 8e16928..d1a775a 100644 --- a/tests/commands/test_env.py +++ b/tests/commands/test_env.py @@ -246,10 +246,53 @@ def test_show_lists_envs_and_redacts_key(isolated_envs): def test_show_marks_current_env(isolated_envs, monkeypatch): save_env("active", {"CDA_API_ROOT": "https://x"}) monkeypatch.setenv("ENVIRONMENT", "active") + monkeypatch.setenv("CDA_API_ROOT", "https://x") runner = CliRunner() result = runner.invoke(env_group, ["show"]) assert "Current environment:" in result.output assert "* active" in result.output + assert "values differ" not in result.output + + +def test_show_warns_when_current_shell_differs(isolated_envs, monkeypatch): + save_env( + "active", + { + "ENVIRONMENT": "active", + "CDA_API_ROOT": "https://expected", + "CDA_API_KEY": "expected-secret", + "OFFICE": "SWT", + }, + ) + monkeypatch.setenv("ENVIRONMENT", "active") + monkeypatch.setenv("CDA_API_ROOT", "https://unexpected") + monkeypatch.setenv("CDA_API_KEY", "unexpected-secret") + monkeypatch.delenv("OFFICE", raising=False) + + result = CliRunner().invoke(env_group, ["show"]) + + assert result.exit_code == 0 + assert "Current environment: active (values differ)" in result.output + assert "current shell values do not match environment 'active'" in result.output + assert ( + "CDA_API_ROOT: expected 'https://expected', found 'https://unexpected'" + in result.output + ) + assert "CDA_API_KEY: current value does not match" in result.output + assert "OFFICE: expected 'SWT', but it is not set" in result.output + assert "Shell startup configuration may have overridden" in result.output + assert "expected-secret" not in result.output + assert "unexpected-secret" not in result.output + + +def test_show_warns_when_selected_environment_is_unknown(isolated_envs, monkeypatch): + monkeypatch.setenv("ENVIRONMENT", "missing") + + result = CliRunner().invoke(env_group, ["show"]) + + assert result.exit_code == 0 + assert "ENVIRONMENT references 'missing'" in result.output + assert "environment is not configured" in result.output # ---------- env show --check ---------- @@ -459,6 +502,84 @@ def test_delete_missing_env_errors(isolated_envs): assert "not found" in result.output +# ---------- env activate ---------- + + +def test_activate_help_explains_shell_startup_precedence(): + runner = CliRunner() + result = runner.invoke(env_group, ["activate", "--help"]) + + assert result.exit_code == 0 + assert "Shell startup files can" in result.output + assert "Solaris profiles" in result.output + assert "cwms-cli env export --format bash" in result.output + assert "cwms-cli env show --check" in result.output + + +@pytest.mark.parametrize( + ("shell", "startup_config", "reapply_command"), + [ + ( + "/bin/bash", + "startup files such as .bashrc", + 'eval "$(cwms-cli env export demo --format bash)"', + ), + ( + "powershell.exe", + "PowerShell profiles", + ( + "cwms-cli env export demo --format powershell " + "| Out-String | Invoke-Expression" + ), + ), + ( + "cmd.exe", + "cmd.exe AutoRun commands", + ( + "cwms-cli env export demo --format cmd " + "--output %TEMP%\\cwms-env.cmd && call %TEMP%\\cwms-env.cmd" + ), + ), + ], +) +def test_activate_warns_about_shell_startup_configuration( + isolated_envs, + monkeypatch, + shell, + startup_config, + reapply_command, +): + save_env( + "demo", + { + "ENVIRONMENT": "demo", + "CDA_API_ROOT": "https://x.mil/cwms-data", + "CDA_API_KEY": "secret", + "OFFICE": "SWT", + }, + ) + monkeypatch.setattr("cwmscli.commands.env._detect_shell", lambda: shell) + monkeypatch.setattr( + "cwmscli.commands.env.subprocess.run", + lambda command, env: subprocess.CompletedProcess(command, 0), + ) + + result = CliRunner().invoke(env_group, ["activate", "demo"]) + + assert result.exit_code == 0 + assert f"Warning: {startup_config} may override CDA_API_ROOT" in result.output + assert "CDA_API_KEY, OFFICE, or ENVIRONMENT" in result.output + assert "verify the environment and CDA connection" in result.output + assert "cwms-cli env show --check" in result.output + assert "If those values do not match 'demo' after startup" in result.output + assert reapply_command in result.output + assert "secret" not in result.output + if shell == "/bin/bash": + assert "or press Ctrl+D" in result.output + else: + assert "Ctrl+D" not in result.output + + # ---------- quoting helpers ----------