Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ jobs:
# hung download is killed and the next attempt retries, instead of wedging the cell
# until it's cancelled. The shim lands in choco's bin dir (machine-wide, already on the
# runner PATH), so the parent shell and later steps pick it up.
#
# During a sustained community.chocolatey.org outage the feed returns 503s *quickly*,
# so every bounded attempt fails fast and the retry loop exhausts with no ffmpeg. Fall
# back to a static build off GitHub's release CDN (a different, far more reliable origin)
# and prepend its dir to GITHUB_PATH so later steps see it.
- name: System deps (ffmpeg)
shell: pwsh
run: |
Expand All @@ -172,6 +177,17 @@ jobs:
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { break }
Start-Sleep -Seconds 5
}
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Host "choco couldn't provide ffmpeg; downloading a static build from GitHub…"
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
$zip = Join-Path $env:RUNNER_TEMP "ffmpeg.zip"
$dest = Join-Path $env:RUNNER_TEMP "ffmpeg"
Invoke-WebRequest -Uri $url -OutFile $zip
Expand-Archive -Path $zip -DestinationPath $dest -Force
$bin = (Get-ChildItem -Path $dest -Recurse -Filter ffmpeg.exe | Select-Object -First 1).DirectoryName
$env:PATH = "$bin;$env:PATH"
Add-Content -Path $env:GITHUB_PATH -Value $bin
}
ffmpeg -version

- name: Install uv (cached)
Expand Down
2 changes: 1 addition & 1 deletion REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Product-scoped variables are `ASSEMBLYAI_*`; CLI-behavior variables are
| `ASSEMBLYAI_API_KEY` | API key for all API calls; beats the keyring, loses to nothing but a `--api-key` validation flag. |
| `AAI_ENV` | Backend environment (`production`, `sandbox000`); beats the profile's stored env, loses to `--env`/`--sandbox`. The non-production environments are internal: selecting one (here, via `--env`/`--sandbox`, or a profile binding) is rejected with exit 2 unless the active profile is signed in with an `@assemblyai.com` login, and `--env`/`--sandbox` and the sandbox-only commands are hidden from `--help` for everyone else. |
| `AAI_AUTH_PORT` | Loopback callback port for `assembly login` (dev/test only; default 8585). |
| `AAI_NO_UPDATE_CHECK` | Disables the "update available" notice and its background refresh. |
| `AAI_NO_UPDATE_CHECK` | Disables the "update available" notice, its interactive "update now?" prompt, and the background refresh. |
| `AAI_TELEMETRY_DISABLED` / `DO_NOT_TRACK` | Disables anonymous usage telemetry (always beats the persisted choice). |
| `NO_COLOR` / `FORCE_COLOR` | Standard color overrides; `--color always` / `--color never` sets them for child consoles too. |
| `CI` | Suppresses interactive affordances (spinners, the update notice); never changes output shape. |
Expand Down
10 changes: 10 additions & 0 deletions aai_cli/core/procs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,13 @@ def spawn_detached(cli_args: list[str], *, disable_env_var: str) -> None:
start_new_session=True,
env={**os.environ, disable_env_var: "1"},
)


def run_foreground(argv: list[str]) -> int:
"""Run ``argv`` to completion in the foreground and return its exit status.

The opposite of ``spawn_detached``: stdio is *inherited*, so the child's output
streams straight to the terminal. Backs the interactive update prompt, where the
user watches the brew/uv/curl installer run. S603 is ignored project-wide.
"""
return subprocess.run(argv, check=False).returncode
Comment on lines +31 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds run_foreground() that executes arbitrary argv (including shell-executed pipeline commands). Confirm the invoked commands are safe and audited before running.

Show fix
Suggested change
def run_foreground(argv: list[str]) -> int:
"""Run ``argv`` to completion in the foreground and return its exit status.
The opposite of ``spawn_detached``: stdio is *inherited*, so the child's output
streams straight to the terminal. Backs the interactive update prompt, where the
user watches the brew/uv/curl installer run. S603 is ignored project-wide.
"""
return subprocess.run(argv, check=False).returncode
Details

✨ AI Reasoning
​The new run_foreground() wrapper calls subprocess.run(argv, check=False) with inherited stdio (as intended in description). This will execute the argv produced by _upgrade_argv, including a sh -c wrapper for a curl | sh pipeline. Although not obfuscation, it materially enables executing remote, dynamic code in the user's terminal. This change was added in the PR and increases risk of running unreviewed remote script content.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

57 changes: 56 additions & 1 deletion aai_cli/ui/update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,29 @@

from __future__ import annotations

import shlex
import sys
import time

import typer
from packaging.version import InvalidVersion, Version
from rich.console import Group
from rich.panel import Panel
from rich.text import Text

from aai_cli import __version__
from aai_cli.core import config, env, procs
from aai_cli.core import config, env, procs, stdio
from aai_cli.core.errors import CLIError
from aai_cli.ui import output

ENV_DISABLED = "AAI_NO_UPDATE_CHECK"
_RELEASES_URL = "https://api.github.com/repos/AssemblyAI/cli/releases/latest"
DOCS_URL = "https://github.com/AssemblyAI/cli#installation"
_INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh"
# Generic fallback when the install channel is unknown: the canonical one-liner
# installer, which re-installs over any existing copy (it runs through a shell
# because of the pipe — see ``_upgrade_argv``).
_INSTALL_SCRIPT_COMMAND = f"curl -LsSf {_INSTALL_SCRIPT_URL} | sh"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introduces a curl | sh one-liner in _INSTALL_SCRIPT_COMMAND, which fetches and executes remote code at runtime. Review for security implications before allowing execution.

Details

✨ AI Reasoning
​The change introduces a module-level constant _INSTALL_SCRIPT_COMMAND that contains a curl | sh pipeline. This constructs a runtime command string that will fetch and immediately execute remote shell script content. Dynamic execution of remote scripts via shell pipelines can hide what is run until runtime and is a common vector for supply-chain or remote-code execution attacks. The pattern is deliberate and was added by the PR (new line), so it should be flagged for review.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
_FETCH_TIMEOUT_SECONDS = 5.0
_USER_AGENT = f"assembly-cli/{__version__}"
Expand Down Expand Up @@ -65,6 +72,24 @@ def detect_upgrade_command() -> str:
)


def resolve_upgrade_command() -> str:
"""The command that upgrades the running install, always non-empty.

The detected channel command (brew/pipx/uv) when known, otherwise the canonical
install-script one-liner — which works regardless of how the CLI was installed.
"""
return detect_upgrade_command() or _INSTALL_SCRIPT_COMMAND


def _upgrade_argv(command: str) -> list[str]:
"""The argv for running ``command``. The install-script fallback is a shell
pipeline (``curl … | sh``) so it runs through ``sh -c``; the package-manager
commands are plain argv split on whitespace."""
if command == _INSTALL_SCRIPT_COMMAND:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constructs shell invocation ['sh','-c', command] for pipeline commands, enabling execution of dynamically sourced shell content; ensure the command is trusted and reviewed.

Details

✨ AI Reasoning
​The newly added _upgrade_argv() returns ['sh', '-c', command] when the fallback pipeline command is chosen. Wrapping a dynamic string in sh -c executes the whole string via the shell, which can obscure exact operations performed and the sources of inputs. This was added in the PR (new function and branch) and increases the potential for executing unexpected shell constructs at runtime.

🔧 How do I fix it?
Ensure code is transparent and not intentionally obfuscated. Avoid hiding functionality from code review. Focus on intent and deception, not specific patterns.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

return ["sh", "-c", command]
return shlex.split(command)


def fetch_and_cache() -> None:
"""Fetch the latest release tag from GitHub and cache it. Best-effort.

Expand Down Expand Up @@ -128,6 +153,35 @@ def _render(current: str, latest: str) -> None:
output.error_console.print(panel)


def _confirm_upgrade() -> bool:
"""Ask whether to upgrade now (interactive sessions only). Default is No, so a
bare Enter declines; an aborted prompt (Ctrl-C / EOF) is treated as No too."""
try:
return typer.confirm("Update now?", default=False, err=True)
except (typer.Abort, EOFError):
return False


def _report_upgrade(latest: str, command: str, returncode: int) -> None:
if returncode == 0:
msg = f"Updated to {latest}. Restart assembly to use it."
output.error_console.print(output.success(msg))
else:
output.error_console.print(output.fail(f"Update failed — run '{command}' manually."))


def _maybe_prompt_upgrade(latest: str) -> None:
"""After the notice, offer to run the upgrade in place. Only when stdin is a real
terminal, so a human can answer; a piped/redirected stdin is left untouched."""
if not stdio.stdin_is_tty():
return
command = resolve_upgrade_command()
if not _confirm_upgrade():
return
returncode = procs.run_foreground(_upgrade_argv(command))
_report_upgrade(latest, command, returncode)


def _cache_is_stale(last_check: float | None, *, now: float) -> bool:
if last_check is None:
return True
Expand All @@ -153,5 +207,6 @@ def _maybe_notify(*, json_mode: bool) -> None:
now = time.time()
if latest is not None and is_newer(latest, __version__):
_render(__version__, latest)
_maybe_prompt_upgrade(latest)
if _cache_is_stale(last_check, now=now):
spawn_refresh()
20 changes: 20 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import pytest
from keyring.backend import KeyringBackend

from aai_cli.ui import theme

# Captured at import, before `isolate_env` strips ASSEMBLYAI_API_KEY from the
# environment. The e2e suite uses this real key to drive the CLI as a subprocess;
# unit tests still run fully isolated.
Expand Down Expand Up @@ -112,6 +114,24 @@ def pin_timezone(monkeypatch):
time.tzset()


@pytest.fixture(autouse=True)
def _reset_theme_style_cache():
# Rich caches each Style's rendered ANSI in Style._ansi on first render and does NOT
# key that cache on the color system (rich/style.py: `_make_ansi_codes` fills
# `self._ansi` once, and `render` returns it thereafter). The `theme.THEME` styles are
# module globals shared by every console make_console builds, so whichever console
# renders a given `aai.*` style *first* pins its color depth for the rest of the
# process: a test that renders e.g. aai.error through a no-color/standard console
# poisons the shared Style, and a later test asserting the *truecolor* ANSI
# (test_setup_render / test_transcripts color tests) gets the stale 16-color downgrade.
# That's an order-dependent flake pytest-randomly flips green/red by seed (and it bit
# both Linux and the Windows matrix). Reset the per-Style cache before each test so
# every test renders the theme from a pristine state. Same hermeticity rationale as the
# rendering fixtures above; `_environ={}` alone can't fix it (the cache isn't env-keyed).
for style in theme.THEME.styles.values():
style._ansi = None


@pytest.fixture(autouse=True)
def fixed_render_size(monkeypatch):
# Pin the render width/height for the *whole* suite so anything that renders
Expand Down
160 changes: 160 additions & 0 deletions tests/test_update_prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""The interactive "update now?" prompt that the startup notice offers."""

from __future__ import annotations

import io
import time
import types

from rich.console import Console

from aai_cli.core import config, procs, stdio
from aai_cli.ui import output, theme, update_check


def _tty_console() -> tuple[Console, io.StringIO]:
# A theme-aware console reporting as a terminal, color env pinned for stable output
# (mirrors the helper in test_update_check.py).
buf = io.StringIO()
return theme.make_console(file=buf, force_terminal=True, width=80, _environ={}), buf


def test_resolve_upgrade_command_uses_detected_channel(monkeypatch):
monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly")
assert update_check.resolve_upgrade_command() == "brew upgrade assembly"


def test_resolve_upgrade_command_falls_back_to_install_script(monkeypatch):
# Unknown install channel -> the canonical curl|sh installer, not an empty string.
monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "")
command = update_check.resolve_upgrade_command()
assert command == update_check._INSTALL_SCRIPT_COMMAND
assert "install.sh" in command


def test_upgrade_argv_runs_install_script_through_a_shell():
# The fallback is a pipeline (curl … | sh), so it must go through `sh -c`, not be
# split into bare argv (which would hand `|` and `sh` to curl as arguments).
argv = update_check._upgrade_argv(update_check._INSTALL_SCRIPT_COMMAND)
assert argv == ["sh", "-c", update_check._INSTALL_SCRIPT_COMMAND]


def test_upgrade_argv_splits_package_manager_command():
assert update_check._upgrade_argv("brew upgrade assembly") == ["brew", "upgrade", "assembly"]


def test_run_foreground_inherits_stdio_and_returns_status(monkeypatch):
calls = {}

def fake_run(argv, *, check):
calls["argv"] = argv
calls["check"] = check
return types.SimpleNamespace(returncode=7)

monkeypatch.setattr("aai_cli.core.procs.subprocess.run", fake_run)

assert procs.run_foreground(["brew", "upgrade", "assembly"]) == 7
assert calls["argv"] == ["brew", "upgrade", "assembly"]
assert calls["check"] is False # exit status is inspected, never raised


def _enable_prompt(tmp_path, monkeypatch) -> io.StringIO:
"""Cache a newer version, a tty stderr console, and an interactive stdin so the
update notice renders and the upgrade prompt is reachable."""
monkeypatch.setattr(config, "config_dir", lambda: tmp_path)
config.set_update_cache(last_check=time.time(), latest_version="9.9.9")
con, buf = _tty_console()
monkeypatch.setattr(output, "error_console", con)
monkeypatch.delenv("CI", raising=False)
monkeypatch.delenv(update_check.ENV_DISABLED, raising=False)
monkeypatch.setattr(stdio, "stdin_is_tty", lambda: True)
return buf


def test_prompt_runs_upgrade_when_confirmed(tmp_path, monkeypatch):
buf = _enable_prompt(tmp_path, monkeypatch)
monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly")

confirm = {}

def fake_confirm(text, *, default, err):
confirm["text"] = text
confirm["default"] = default
confirm["err"] = err
return True

monkeypatch.setattr(update_check.typer, "confirm", fake_confirm)

ran = {}

def fake_run_foreground(argv):
ran["argv"] = argv
return 0

monkeypatch.setattr(procs, "run_foreground", fake_run_foreground)

update_check.maybe_notify(json_mode=False)

assert ran["argv"] == ["brew", "upgrade", "assembly"] # the detected channel ran
assert "Update now?" in confirm["text"] # the prompt actually asks
assert confirm["default"] is False # default-No: a bare Enter declines
assert confirm["err"] is True # prompt rides stderr, like the notice
out = buf.getvalue()
assert "Updated to" in out
assert "9.9.9" in out
assert "Restart" in out # tells the user the new binary takes over next run


def test_prompt_skips_upgrade_when_declined(tmp_path, monkeypatch):
buf = _enable_prompt(tmp_path, monkeypatch)
monkeypatch.setattr(update_check.typer, "confirm", lambda *a, **k: False)

ran = []

def fake_run_foreground(argv):
ran.append(argv)
return 0

monkeypatch.setattr(procs, "run_foreground", fake_run_foreground)

update_check.maybe_notify(json_mode=False)

assert ran == [] # declining runs nothing
assert "Update available" in buf.getvalue() # the notice still showed


def test_no_upgrade_prompt_when_stdin_not_a_tty(tmp_path, monkeypatch):
buf = _enable_prompt(tmp_path, monkeypatch)
monkeypatch.setattr(stdio, "stdin_is_tty", lambda: False) # piped/redirected stdin

asked = []
monkeypatch.setattr(update_check.typer, "confirm", lambda *a, **k: asked.append(True))

update_check.maybe_notify(json_mode=False)

assert asked == [] # a non-interactive stdin is never prompted
assert "Update available" in buf.getvalue() # but the notice still renders


def test_prompt_reports_failure_when_upgrade_errors(tmp_path, monkeypatch):
buf = _enable_prompt(tmp_path, monkeypatch)
monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly")
monkeypatch.setattr(update_check.typer, "confirm", lambda *a, **k: True)
monkeypatch.setattr(procs, "run_foreground", lambda argv: 3) # non-zero exit

update_check.maybe_notify(json_mode=False)

out = buf.getvalue()
assert "Update failed" in out
assert "brew upgrade assembly" in out # the command to re-run by hand


def test_confirm_upgrade_treats_aborted_prompt_as_no(monkeypatch):
# Ctrl-C (Abort) or Ctrl-D (EOFError) at the prompt must read as "no", never crash.
for exc in (update_check.typer.Abort, EOFError):

def boom(*a, _exc=exc, **k):
raise _exc()

monkeypatch.setattr(update_check.typer, "confirm", boom)
assert update_check._confirm_upgrade() is False
Loading