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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `tr

- **`context.py`** — `AppState` (profile, env) is attached to the Typer context in the root `@app.callback()`. `run_command` is the standard command wrapper.
- **`config.py`** — profiles persisted in `config.toml` (via `platformdirs`); the **API key lives only in the OS keyring** (`KEYRING_SERVICE = "assemblyai-cli"`), never in a dotfile. Key resolution order: `--api-key` flag (validation paths only) → `ASSEMBLYAI_API_KEY` env → keyring. **Run commands deliberately expose no `--api-key` flag** so keys can't leak into `ps`/shell history.
- **`environments.py`** — a frozen `Environment` (api_base, streaming_host, llm_gateway_base, ams_base, stytch_*). `DEFAULT_ENV` is currently **`sandbox000`** (flip to `production` once the prod Stytch value is real). The active environment is a process-global set once at startup; precedence: `--env` → `AAI_ENV` → profile's stored env → default. A credential is only valid against the environment that minted it.
- **`environments.py`** — a frozen `Environment` (api_base, streaming_host, llm_gateway_base, ams_base, stytch_*). `DEFAULT_ENV` is **`production`**; use `--sandbox` (or `--env sandbox000` / `AAI_ENV`) to target the sandbox. The active environment is a process-global set once at startup; precedence: `--env` → `AAI_ENV` → profile's stored env → default. A credential is only valid against the environment that minted it.
- **`client.py`** — thin wrappers over the `assemblyai` SDK (`transcribe`, `list_transcripts`, `stream_audio`, etc.). It normalizes SDK exceptions: auth failures become a single clean `auth_failure()` `CLIError`; everything else becomes `APIError`. New SDK calls should follow this try/except shape.
- **`errors.py`** — the `CLIError` hierarchy (each with `error_type` + `exit_code`). `output.py` emits errors to **stderr**; stdout stays clean for pipelines. `--json` (auto-enabled when piped/agent-run) switches to machine-readable output.

Expand Down
16 changes: 5 additions & 11 deletions aai_cli/auth/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,22 @@
from aai_cli.auth import endpoints


def build_start_url(state: str) -> str:
def build_start_url() -> str:
"""The Stytch B2B OAuth discovery *start* URL the browser opens.

Client-side endpoint authenticated by the project's public token. After the
user authenticates with the provider, Stytch redirects to our loopback
`discovery_redirect_url` with `?stytch_token_type=discovery_oauth&token=...`.

`state` is a single-use random nonce carried as a query parameter *on the
redirect URL*. Stytch validates the redirect URL by scheme/host/port/path and
preserves query parameters through the flow, so the nonce comes back on the
callback unchanged; `loopback.capture_callback` rejects any callback whose
`state` doesn't match. This stops a malicious page from completing someone
else's login at the loopback server (login CSRF / account confusion) by
replaying an attacker-minted discovery token while `aai login` is waiting.
The redirect URL is the bare loopback path Stytch validates by
scheme/host/port/path, with no extra query parameters (so the redirect needs
no wildcard registered in the Stytch dashboard).
"""
base = (
f"{endpoints.stytch_domain()}"
f"/v1/b2b/public/oauth/{endpoints.STYTCH_OAUTH_PROVIDER}/discovery/start"
)
redirect_with_state = f"{endpoints.redirect_uri()}?{urlencode({'state': state})}"
params = {
"public_token": endpoints.stytch_public_token(),
"discovery_redirect_url": redirect_with_state,
"discovery_redirect_url": endpoints.redirect_uri(),
}
return f"{base}?{urlencode(params)}"
13 changes: 4 additions & 9 deletions aai_cli/auth/flow.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import secrets
import webbrowser
from dataclasses import dataclass

Expand Down Expand Up @@ -98,8 +97,8 @@ def _open_browser(url: str) -> None:
)


def _capture(state: str) -> loopback.CallbackResult:
return loopback.capture_callback(state)
def _capture() -> loopback.CallbackResult:
return loopback.capture_callback()


def _reusable_cli_key(token: _Token) -> str | None:
Expand Down Expand Up @@ -138,12 +137,8 @@ def find_or_create_cli_key(account_id: int, session_jwt: str) -> str:

def run_login_flow() -> LoginResult:
"""Drive the full browser + AMS login and return a LoginResult."""
# A fresh single-use nonce binds the browser hand-off to this loopback capture:
# build_start_url carries it to Stytch, capture_callback only accepts a callback
# that echoes it back. token_urlsafe(32) is 256 bits of entropy — unguessable.
state = secrets.token_urlsafe(32) # pragma: no mutate (any large nonce works; 32 isn't magic)
_open_browser(discovery.build_start_url(state))
result = _capture(state)
_open_browser(discovery.build_start_url())
result = _capture()

if result.error == "timeout":
raise APIError(
Expand Down
25 changes: 11 additions & 14 deletions aai_cli/auth/loopback.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import secrets
import threading
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
Expand All @@ -9,7 +8,7 @@
from aai_cli.auth import endpoints
from aai_cli.errors import APIError

# The callback URL carries the single-use OAuth token (and the state nonce) in its
# The callback URL carries the single-use OAuth token in its
# query string, so it would otherwise linger in the browser's history and address
# bar. Scrub it from the current history entry with replaceState the moment the page
# loads — no extra request to race the server shutdown, unlike a redirect. The token
Expand All @@ -32,16 +31,14 @@ class CallbackResult:


def capture_callback(
expected_state: str,
timeout: float = 120.0, # pragma: no mutate (default window; tests pass explicit timeouts)
) -> CallbackResult:
"""Bind the fixed loopback port, capture one OAuth callback, return its token.

Only a callback whose `state` query parameter equals `expected_state` is
accepted; any other request (wrong/missing state, or a different path) gets a
4xx and the server keeps waiting, so a forged callback can't complete someone
else's login. Returns a CallbackResult; `error="timeout"` if no matching
callback arrives in time.
Only a callback to the registered path that carries a `token` is accepted; any
other request (a different path, or no token) gets a 4xx and the server keeps
waiting, so a stray request can't end the capture early. Returns a
CallbackResult; `error="timeout"` if no matching callback arrives in time.
"""
result = CallbackResult()
done = threading.Event()
Expand All @@ -54,15 +51,15 @@ def do_GET(self) -> None: # stdlib API name
self.end_headers()
return
qs = parse_qs(parsed.query)
state = next(iter(qs.get("state", [])), None)
# Constant-time compare so a forged callback can't probe the nonce by
# timing. A mismatch is rejected without ending the capture: the real
# callback can still arrive (otherwise it falls through to timeout).
if state is None or not secrets.compare_digest(state, expected_state):
token = next(iter(qs.get("token", [])), None)
# A callback with no token (a stray or preflight request) is rejected
# without ending the capture: the genuine callback can still arrive
# (otherwise it falls through to timeout).
if token is None:
self.send_response(400)
self.end_headers()
return
result.token = next(iter(qs.get("token", [])), None)
result.token = token
result.token_type = next(iter(qs.get("stytch_token_type", [])), None)
self.send_response(200)
self.send_header("Content-Type", "text/html")
Expand Down
80 changes: 76 additions & 4 deletions aai_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import tomli_w
from pydantic import BaseModel, ConfigDict, Field, ValidationError

from aai_cli.errors import NotAuthenticated
from aai_cli.errors import CLIError, NotAuthenticated

KEYRING_SERVICE = "assemblyai-cli"
ENV_API_KEY = "ASSEMBLYAI_API_KEY"
Expand Down Expand Up @@ -123,9 +123,42 @@ def get_active_profile() -> str:
return _load().active_profile or DEFAULT_PROFILE


def _keyring_set(username: str, secret: str) -> None:
"""Write a secret to the OS keyring, turning backend failures into a clean error.

A locked keychain, or an existing entry whose ACL is bound to another app, makes
keyring raise a KeyringError (e.g. macOS errSecInvalidOwnerEdit, -25244). Surface
it as a CLIError so the command prints a fixable message instead of a traceback.
"""
try:
keyring.set_password(KEYRING_SERVICE, username, secret)
except keyring.errors.KeyringError as exc:
raise CLIError(
f"Your OS keyring rejected the write ({exc}).",
error_type="keyring_error",
suggestion=(
"Unlock your keyring, or remove the stale 'assemblyai-cli' entry and "
"retry (macOS: security delete-generic-password -s assemblyai-cli)."
),
) from exc


def _keyring_restore(username: str, prior: str | None) -> None:
"""Best-effort restore of a keyring entry to a snapshot value, for login rollback.

Suppresses keyring errors (including a delete of an absent entry) so a failed
rollback never masks the original write error that triggered it.
"""
with contextlib.suppress(keyring.errors.KeyringError):
if prior is None:
keyring.delete_password(KEYRING_SERVICE, username)
else:
keyring.set_password(KEYRING_SERVICE, username, prior)


def set_api_key(profile: str, api_key: str) -> None:
_validate_profile(profile)
keyring.set_password(KEYRING_SERVICE, profile, api_key)
_keyring_set(profile, api_key)
cfg = _load()
cfg.profiles.setdefault(profile, Profile())
if cfg.active_profile is None:
Expand Down Expand Up @@ -170,8 +203,7 @@ def set_session(profile: str, *, session_jwt: str, session_token: str, account_i
key. The JWT is short-lived; an expired session surfaces as NotAuthenticated.
"""
_validate_profile(profile)
keyring.set_password(
KEYRING_SERVICE,
_keyring_set(
_session_username(profile),
StoredSession(jwt=session_jwt, token=session_token).model_dump_json(),
)
Expand Down Expand Up @@ -208,6 +240,46 @@ def clear_session(profile: str) -> None:
_dump(cfg)


def persist_login(
profile: str,
*,
api_key: str,
env: str,
session_jwt: str,
session_token: str,
account_id: int,
) -> None:
"""Atomically persist a full browser-login result (API key + env + session).

The three writes span the keyring and config.toml, so a mid-sequence failure
(e.g. a locked keychain after the key is already stored) would otherwise leave a
half-written profile — an API key with no session, which looks signed-in but
can't reach AMS. On any failure the pre-login snapshot is restored: config.toml
is rewritten verbatim in one atomic dump, and the two keyring entries are
restored best-effort.
"""
_validate_profile(profile)
prior_api_key = keyring.get_password(KEYRING_SERVICE, profile)
prior_session = keyring.get_password(KEYRING_SERVICE, _session_username(profile))
prior_cfg = _load()
done = False
try:
set_api_key(profile, api_key)
set_profile_env(profile, env)
set_session(
profile,
session_jwt=session_jwt,
session_token=session_token,
account_id=account_id,
)
done = True
finally:
if not done:
_keyring_restore(profile, prior_api_key)
_keyring_restore(_session_username(profile), prior_session)
_dump(prior_cfg)


def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = None) -> str:
if api_key_flag is not None:
if not api_key_flag:
Expand Down
6 changes: 3 additions & 3 deletions aai_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,10 @@ def env_override_warning(state: AppState) -> str | None:
def persist_browser_login(profile: str, env: str) -> None:
"""Run the browser login flow and persist its credentials for `profile`/`env`."""
result = run_login_flow()
config.set_api_key(profile, result.api_key)
config.set_profile_env(profile, env)
config.set_session(
config.persist_login(
profile,
api_key=result.api_key,
env=env,
session_jwt=result.session_jwt,
session_token=result.session_token,
account_id=result.account_id,
Expand Down
20 changes: 12 additions & 8 deletions aai_cli/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,23 @@ class Environment:
signup_url: str # where a first-time user creates an account


# Stytch *public* client tokens — safe to ship, not secrets despite the field name.
# Held as module constants (not inline literals) so the constructor kwarg takes a
# name, not a string, which is what would otherwise trip ruff's S106 hardcoded-secret
# heuristic on the `stytch_public_token=` argument.
_PROD_STYTCH_PUBLIC = "public-token-live-bbc59d30-d3c8-4815-a5be-fede00306680"
_SANDBOX_STYTCH_PUBLIC = "public-token-test-a161155e-7e9b-4dd1-9d43-493c899b4117"

ENVIRONMENTS: dict[str, Environment] = {
"production": Environment(
name="production",
api_base="https://api.assemblyai.com",
streaming_host="streaming.assemblyai.com",
agents_host="agents.assemblyai.com",
llm_gateway_base="https://llm-gateway.assemblyai.com/v1",
# NOTE: production Stytch is not provisioned yet (see the REPLACE_ME
# token), which is why DEFAULT_ENV stays "sandbox000". Tracked under
# spec O4.
ams_base="https://ams.internal.assemblyai-labs.com",
stytch_domain="https://api.stytch.com",
stytch_public_token="public-token-live-REPLACE_ME", # noqa: S106 - public token, safe to ship
stytch_public_token=_PROD_STYTCH_PUBLIC,
signup_url="https://www.assemblyai.com/dashboard",
),
"sandbox000": Environment(
Expand All @@ -48,14 +52,14 @@ class Environment:
llm_gateway_base="https://llm-gateway.sandbox000.assemblyai-labs.com/v1",
ams_base="https://ams.sandbox000.assemblyai-labs.com",
stytch_domain="https://test.stytch.com",
stytch_public_token="public-token-test-a161155e-7e9b-4dd1-9d43-493c899b4117", # noqa: S106 - public token, safe to ship
stytch_public_token=_SANDBOX_STYTCH_PUBLIC,
signup_url="https://dashboard-assemblyai.vercel.app/dashboard/login",
),
}

# Shipped default when nothing selects an environment. Flip to "production" at
# release once the production Stytch value above is real.
DEFAULT_ENV = "sandbox000"
# Shipped default when nothing selects an environment. Use --sandbox (or
# --env sandbox000 / AAI_ENV) to target the sandbox instead.
DEFAULT_ENV = "production"

# The environment in effect for this process, set once at CLI startup (like
# aai.settings). Resolved from --env / AAI_ENV / the profile's stored env.
Expand Down
2 changes: 1 addition & 1 deletion aai_cli/skills/aai-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ shell history. Do not look for one.

**Environment binding.** The backend environment is selected by `--env`
(or `AAI_ENV`, or the profile's stored env). `--sandbox` is shorthand for
`--env sandbox000`. The default environment is currently `sandbox000`.
`--env sandbox000`. The default environment is `production`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changed the documented default environment to 'production'. This can cause users to unknowingly run commands against production; avoid defaulting to production or clearly warn and require explicit opt-in.

Details

✨ AI Reasoning
​The change makes the CLI's documented default environment 'production' instead of 'sandbox000'. This increases the chance that users (especially new users or CI without explicit --env) will run commands against production, potentially performing destructive or sensitive operations without explicit confirmation. The modification narrows the safety margin provided by a sandbox default and therefore meaningfully worsens the security posture relative to the prior state.

🔧 How do I fix it?
Ensure skill actions match the description. Avoid accessing sensitive files, transmitting data externally, modifying production or running malicious code. Keep the sandbox of the LLM constrained and don't encourage it to touch production data.

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

**A credential is only valid against the environment that minted it** — a
sandbox key fails against production and vice-versa. If a freshly-working key
suddenly returns auth errors, check you are on the same `--env` you logged in
Expand Down
18 changes: 9 additions & 9 deletions tests/test_auth_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,26 @@


def test_build_start_url_targets_b2b_discovery_for_provider():
url = discovery.build_start_url("state-xyz")
url = discovery.build_start_url()
parsed = urlparse(url)
assert parsed.scheme == "https"
assert parsed.path == "/v1/b2b/public/oauth/google/discovery/start"
assert url.startswith(endpoints.stytch_domain())


def test_build_start_url_includes_public_token_and_redirect():
url = discovery.build_start_url("state-xyz")
url = discovery.build_start_url()
qs = parse_qs(urlparse(url).query)
assert qs["public_token"] == [endpoints.stytch_public_token()]
# The state nonce rides as a query param on the (still path-exact) redirect URL.
assert qs["discovery_redirect_url"] == ["http://127.0.0.1:8585/callback?state=state-xyz"]
# The redirect URL is the bare loopback path Stytch validates — no query params.
assert qs["discovery_redirect_url"] == ["http://127.0.0.1:8585/callback"]


def test_build_start_url_carries_state_into_redirect():
url = discovery.build_start_url("nonce-123")
def test_build_start_url_redirect_has_no_query_params():
url = discovery.build_start_url()
redirect = parse_qs(urlparse(url).query)["discovery_redirect_url"][0]
redirect_parsed = urlparse(redirect)
# The redirect path stays exactly /callback (what Stytch validates); only the
# query string gains the nonce, so redirect-URL matching is unaffected.
# Path-exact /callback with an empty query string keeps Stytch's redirect-URL
# matching simple (no query-parameter validation to configure).
assert redirect_parsed.path == "/callback"
assert parse_qs(redirect_parsed.query)["state"] == ["nonce-123"]
assert redirect_parsed.query == ""
6 changes: 3 additions & 3 deletions tests/test_auth_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ def test_redirect_uri_is_fixed_loopback():


def test_env_specific_values_come_from_active_environment():
# With no active env set, helpers fall back to the default (sandbox000).
assert endpoints.ams_base() == "https://ams.sandbox000.assemblyai-labs.com"
assert endpoints.stytch_domain() == "https://test.stytch.com"
# With no active env set, helpers fall back to the default (production).
assert endpoints.ams_base() == "https://ams.internal.assemblyai-labs.com"
assert endpoints.stytch_domain() == "https://api.stytch.com"
assert endpoints.stytch_public_token().startswith("public-token-")
assert endpoints.signup_url().startswith("https://")

Expand Down
Loading
Loading