Skip to content

Commit c4942a6

Browse files
alexkromanclaude
andauthored
Restrict the sandbox to AssemblyAI logins (#208)
The sandbox runs on internal infrastructure that an external account can neither reach nor authenticate against, yet `--sandbox`/`--env sandbox000` and the sandbox-only commands (speak/dub/agent-cascade) were offered to everyone. Gate the whole sandbox surface on the login's email domain: - Capture the email from AMS discovery at browser login and persist it on the profile (config.persist_login); API-key-only profiles have none and so read as external. - core/access.py decides internal vs external from that email (`@assemblyai.com`, fail-closed on a corrupt config). - The root callback rejects an internal-only environment for an external account with a clean exit-2 error, exempting `login` so a first-time employee can still sign in to the sandbox (which records the email). - `assembly --help` hides the sandbox flags and [sandbox] commands from external accounts, restoring them after the render so completion and later in-process renders are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QqLkQANDitxPuBwkQJuCi4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c13e3d commit c4942a6

18 files changed

Lines changed: 494 additions & 5 deletions

REFERENCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Product-scoped variables are `ASSEMBLYAI_*`; CLI-behavior variables are
2929
| Variable | Effect |
3030
| -------- | ------ |
3131
| `ASSEMBLYAI_API_KEY` | API key for all API calls; beats the keyring, loses to nothing but a `--api-key` validation flag. |
32-
| `AAI_ENV` | Backend environment (`production`, `sandbox000`); beats the profile's stored env, loses to `--env`/`--sandbox`. |
32+
| `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. |
3333
| `AAI_AUTH_PORT` | Loopback callback port for `assembly login` (dev/test only; default 8585). |
3434
| `AAI_NO_UPDATE_CHECK` | Disables the "update available" notice and its background refresh. |
3535
| `AAI_TELEMETRY_DISABLED` / `DO_NOT_TRACK` | Disables anonymous usage telemetry (always beats the persisted choice). |

aai_cli/app/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ def persist_browser_login(profile: str, env: str, *, json_mode: bool = False) ->
125125
session_jwt=result.session_jwt,
126126
session_token=result.session_token,
127127
account_id=result.account_id,
128+
email=result.email,
128129
)
129130

130131

aai_cli/auth/flow.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,15 @@ class LoginResult:
2222
session_jwt: str
2323
session_token: str
2424
account_id: int
25+
# The signed-in user's email, from AMS discovery. Persisted so the CLI can gate
26+
# internal-only environments (the sandbox) on the org domain; None if AMS omits it.
27+
email: str | None = None
2528

2629

2730
# Typed views of the AMS login responses. AMS only returns HTTP errors for outright
2831
# failures; a 200 with an unexpected shape would otherwise KeyError into an ugly
2932
# traceback, so each required field's absence becomes the same clean "run login
30-
# again" APIError via `_parse`. Extra fields (e.g. discover's `email`) are ignored.
33+
# again" APIError via `_parse`. Only the fields below are read; the rest are ignored.
3134
class _Organization(BaseModel):
3235
organization_id: str
3336
organization_name: str | None = None
@@ -36,6 +39,8 @@ class _Organization(BaseModel):
3639
class _Discovery(BaseModel):
3740
intermediate_session_token: str
3841
organizations: list[_Organization] = []
42+
# Top-level email from the discover response; used only to gate sandbox access.
43+
email: str | None = None
3944

4045

4146
class _Account(BaseModel):
@@ -240,4 +245,5 @@ def run_login_flow(*, json_mode: bool = False) -> LoginResult:
240245
session_jwt=signed_in.session_jwt,
241246
session_token=signed_in.session_token,
242247
account_id=signed_in.account.id,
248+
email=disc.email,
243249
)

aai_cli/core/access.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Who may select the internal-only environments (the sandbox).
2+
3+
The sandbox runs on internal infrastructure, so it's gated on the login email
4+
captured at browser login (persisted per profile by ``config``), not the API key —
5+
an API-key-only profile (CI, ``ASSEMBLYAI_API_KEY``) therefore reads as external.
6+
The root callback rejects an internal environment for an external account, and the
7+
root ``--help`` hides the sandbox flags/commands from it.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from aai_cli.core import config
13+
from aai_cli.core.errors import CLIError
14+
15+
# Login emails in this domain unlock the internal-only environments.
16+
INTERNAL_EMAIL_DOMAIN = "assemblyai.com"
17+
18+
19+
def is_internal_email(email: str | None) -> bool:
20+
"""Whether ``email`` belongs to the AssemblyAI org (gates sandbox access).
21+
22+
The ``@`` anchors the domain boundary so a look-alike like
23+
``user@evil-assemblyai.com`` is rejected; matching is case-insensitive.
24+
"""
25+
return email is not None and email.strip().lower().endswith("@" + INTERNAL_EMAIL_DOMAIN)
26+
27+
28+
def profile_is_internal(profile: str | None = None) -> bool:
29+
"""Whether a profile's stored login email is an AssemblyAI address.
30+
31+
Reads the active profile when ``profile`` is None. Fails closed: an unreadable
32+
or corrupt config reads as external rather than raising, so the gate never
33+
accidentally grants access (or crashes ``--help``) on a broken config.toml.
34+
"""
35+
try:
36+
name = profile or config.get_active_profile()
37+
return is_internal_email(config.get_profile_email(name))
38+
except CLIError:
39+
return False

aai_cli/core/config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class Profile(BaseModel):
3535

3636
env: str | None = None
3737
account_id: int | None = None
38+
# Login email from AMS discovery; gates internal-environment access (see core.access).
39+
email: str | None = None
3840

3941

4042
class Config(BaseModel):
@@ -291,6 +293,20 @@ def set_profile_env(profile: str, env: str) -> None:
291293
_dump(cfg)
292294

293295

296+
def get_profile_email(profile: str) -> str | None:
297+
"""The login email recorded for a profile at browser login, if any."""
298+
prof = _load().profiles.get(profile)
299+
return prof.email if prof else None
300+
301+
302+
def set_profile_email(profile: str, email: str) -> None:
303+
"""Persist the login email for a profile (gates internal-environment access)."""
304+
validate_profile(profile)
305+
cfg = _load()
306+
cfg.profiles.setdefault(profile, Profile()).email = email
307+
_dump(cfg)
308+
309+
294310
def clear_api_key(profile: str) -> None:
295311
# KeyringError, not just PasswordDeleteError: with no backend at all (headless
296312
# boxes) delete raises NoKeyringError, and "nothing stored" is already the goal.
@@ -357,6 +373,7 @@ def persist_login(
357373
session_jwt: str,
358374
session_token: str,
359375
account_id: int,
376+
email: str | None = None,
360377
) -> None:
361378
"""Atomically persist a full browser-login result (API key + env + session).
362379
@@ -381,6 +398,9 @@ def persist_login(
381398
session_token=session_token,
382399
account_id=account_id,
383400
)
401+
# Within the same atomic rollback so the sandbox gate can't read stale identity.
402+
if email is not None:
403+
set_profile_email(profile, email)
384404
done = True
385405
finally:
386406
if not done:

aai_cli/main.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
from __future__ import annotations
22

3+
import contextlib
34
import logging
45
import sys
6+
from collections.abc import Generator
57
from typing import TYPE_CHECKING
68

79
import typer
810
from typer._click.utils import PacifyFlushWrapper
9-
from typer.core import TyperGroup
11+
from typer.core import TyperGroup, TyperOption
1012

1113
if TYPE_CHECKING:
1214
# Typer (>=0.13) vendors its own click; TyperGroup.list_commands receives this
1315
# context type, not the upstream click.Context. Imported for typing only.
16+
from typer._click.core import Command as ClickCommand
1417
from typer._click.core import Context as ClickContext
18+
from typer._click.formatting import HelpFormatter as ClickHelpFormatter
1519

1620
from aai_cli import __version__, command_registry
1721
from aai_cli.app.context import AppState
1822
from aai_cli.commands import onboard
19-
from aai_cli.core import argscan, choices, debuglog, environments, stdio
23+
from aai_cli.core import access, argscan, choices, debuglog, environments, stdio
24+
from aai_cli.core.environments import Environment
2025
from aai_cli.core.errors import CLIError, NotAuthenticated
2126
from aai_cli.onboard import wizard
2227
from aai_cli.onboard.sections import WizardContext
@@ -38,6 +43,23 @@
3843
_COMMAND_RANK = {name: i for i, name in enumerate(_COMMAND_ORDER)}
3944

4045

46+
# Root flags and the marker the sandbox-only command docstrings open with: the single
47+
# place the "what is a sandbox option" surface is defined, so help-filtering and the
48+
# command docstrings stay the lone declarations (no parallel command list to maintain).
49+
_SANDBOX_ROOT_FLAGS = frozenset({"sandbox", "env"})
50+
_SANDBOX_HELP_MARKER = "[sandbox]"
51+
52+
53+
def _is_sandbox_command(command: ClickCommand) -> bool:
54+
"""Whether a command is sandbox-only, detected by the ``[sandbox]`` help prefix.
55+
56+
The docstrings escape the bracket for Rich (``\\[sandbox]``), so strip a leading
57+
backslash before matching.
58+
"""
59+
text = (command.help or command.short_help or "").lstrip()
60+
return text.lstrip("\\").startswith(_SANDBOX_HELP_MARKER)
61+
62+
4163
class _OrderedGroup(TyperGroup):
4264
"""Lists commands in `_COMMAND_ORDER` rather than registration order.
4365
@@ -59,6 +81,51 @@ def parse_args(self, ctx: ClickContext, args: list[str]) -> list[str]:
5981
ctx.meta[argscan.RAW_ARGS_META_KEY] = list(args)
6082
return super().parse_args(ctx, args)
6183

84+
def _sandbox_surface(self, ctx: ClickContext) -> list[TyperOption | ClickCommand]:
85+
"""The sandbox root flags and ``[sandbox]`` commands — the surface to hide."""
86+
flags: list[TyperOption | ClickCommand] = [
87+
param
88+
for param in self.get_params(ctx)
89+
if isinstance(param, TyperOption) and param.name in _SANDBOX_ROOT_FLAGS
90+
]
91+
commands = [
92+
command
93+
for name in self.list_commands(ctx)
94+
if (command := self.get_command(ctx, name)) is not None and _is_sandbox_command(command)
95+
]
96+
return [*flags, *commands]
97+
98+
@contextlib.contextmanager
99+
def _sandbox_surface_hidden(self, ctx: ClickContext) -> Generator[None]:
100+
"""Mark the sandbox flags/commands ``hidden`` for one render, then restore.
101+
102+
Restored in ``finally``: the parameter/command objects are process-global
103+
(one Typer tree per process), so a leaked ``hidden=True`` would wrongly hide
104+
the sandbox surface from a later in-process render or from shell completion.
105+
"""
106+
targets = self._sandbox_surface(ctx)
107+
saved = [(target, target.hidden) for target in targets]
108+
for target in targets:
109+
target.hidden = True
110+
try:
111+
yield
112+
finally:
113+
for target, was_hidden in saved:
114+
target.hidden = was_hidden
115+
116+
def format_help(self, ctx: ClickContext, formatter: ClickHelpFormatter) -> None:
117+
"""Render `assembly --help`, hiding the sandbox surface from external accounts.
118+
119+
The sandbox runs on internal infrastructure, so its flags and commands are
120+
noise (and a dead end) for an external account — show them only to an
121+
AssemblyAI login. Internal users get the full surface unchanged.
122+
"""
123+
if access.profile_is_internal():
124+
super().format_help(ctx, formatter)
125+
return
126+
with self._sandbox_surface_hidden(ctx):
127+
super().format_help(ctx, formatter)
128+
62129

63130
# Brand-retint Typer's help palette, pin help-table columns against clipping, make
64131
# Typer's consoles pipe-safe, fix Click's error formatting, and trim the completion
@@ -108,6 +175,36 @@ def _sandbox_conflict_warning(sandbox: bool, env: str | None) -> str | None:
108175
return None
109176

110177

178+
def _enforce_internal_env(
179+
ctx: typer.Context, state: AppState, active_env: Environment, *, json_mode: bool
180+
) -> None:
181+
"""Reject an internal-only environment for a profile that isn't an AssemblyAI account.
182+
183+
The sandbox runs on internal infrastructure an external account can neither reach
184+
nor authenticate against, so selecting it (via --sandbox / --env / AAI_ENV) fails
185+
here with a clean error instead of a confusing downstream auth failure. ``login``
186+
is exempt: a first-time employee must be able to target the sandbox to sign in
187+
there, which is what records the email this gate then reads.
188+
"""
189+
if active_env.name == environments.DEFAULT_ENV:
190+
return
191+
if ctx.invoked_subcommand == "login":
192+
return
193+
if access.profile_is_internal(state.resolve_profile()):
194+
return
195+
err = CLIError(
196+
f"The {active_env.name} environment is restricted to AssemblyAI accounts.",
197+
error_type="restricted_environment",
198+
exit_code=2,
199+
suggestion=(
200+
"Drop --sandbox/--env (and unset AAI_ENV) to use production, or run "
201+
"'assembly login' with an AssemblyAI account."
202+
),
203+
)
204+
output.emit_error(err, json_mode=json_mode)
205+
raise typer.Exit(code=err.exit_code)
206+
207+
111208
def _offer_or_help(ctx: typer.Context, state: AppState) -> None:
112209
"""No subcommand given: offer guided setup to a credential-less, interactive user;
113210
otherwise print help. Never prompts in a non-interactive session, never on
@@ -216,6 +313,7 @@ def main(
216313
raise typer.Exit(code=env_err.exit_code) from None
217314
active_env = environments.active()
218315
_LOG.debug("environment: %s (%s)", active_env.name, active_env.api_base)
316+
_enforce_internal_env(ctx, state, active_env, json_mode=json_mode)
219317
for warning in (conflict_warning, state.env_override_warning()):
220318
if warning and not quiet:
221319
# Surfaced in JSON mode too (as {"warning": …}), so a `--json` pipeline gets

scripts/generated_code_compile_gate.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,22 @@
66

77
from typer.testing import CliRunner
88

9+
from aai_cli.core import access
910
from aai_cli.main import app
1011

1112
_ARG_COUNT = 2
1213
_USAGE_EXIT = 2
1314

15+
16+
def _force_internal_account() -> None:
17+
"""Run as an AssemblyAI login so the sandbox-only `--show-code` cases aren't gated.
18+
19+
The root callback restricts sandbox environments to internal logins; this gate
20+
only compiles generated code, not the access check, so stub the predicate True.
21+
"""
22+
access.profile_is_internal = lambda *_args, **_kwargs: True
23+
24+
1425
# Compile exactly what `assembly … --show-code > script.py` would capture: stdout
1526
# only (stderr carries human chrome like warnings), with telemetry disabled so a
1627
# gate run never mints a device id or spawns a flusher on the host.
@@ -39,6 +50,7 @@ def main() -> int:
3950
return _USAGE_EXIT
4051
out_dir = Path(sys.argv[1])
4152
out_dir.mkdir(parents=True, exist_ok=True)
53+
_force_internal_account()
4254

4355
transcribe_config = out_dir / "transcribe-config.json"
4456
transcribe_config.write_text(

tests/AGENTS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ Lessons that cost iterations getting the patch-coverage and mutation tail gates
5454
cost a PR three CI rounds. Don't fight it: a local green is now a CI green for output tests.
5555
A test that genuinely needs a different width passes it on the call
5656
(`runner.invoke(app, argv, env={"COLUMNS": "300"})`), which overrides the default.
57+
- **Never `"--flag" in result.output` on Rich/help output — CI colorizes it and you cannot
58+
turn that off from the test process.** Locally CliRunner captures to a non-tty so output is
59+
plain and the check passes; in CI the render carries ANSI and Rich splits a flag's leading
60+
dash into its own SGR span (`\x1b[..m-\x1b[..m-profile`), so `"--profile" in output` fails —
61+
green locally, red in CI. The *worse* trap is the negative form: `"--sandbox" not in output`
62+
passes **vacuously** against colored text, so a regression that re-exposes a flag sails
63+
through CI undetected. This has bitten many PRs. Trying to disable color in `conftest`
64+
(popping `FORCE_COLOR`, etc.) does **not** work — CI re-colors anyway, and the attempt only
65+
masks the bug locally. The fix is to strip ANSI in the assertion: pass the output through
66+
`tests._snapshot_surface.normalize` (what every `--help` snapshot test already does), then do
67+
the `in` / `not in` checks against the plain text. A test that genuinely needs *colored*
68+
output builds its own console (`theme.make_console(force_terminal=True, _environ={})`), never
69+
the ambient env (see `test_color_mode.py` / `test_output.py`).
5770
- **Typer's `CliRunner` merges stderr into `result.output`, and not in call order**, so don't
5871
assume `splitlines()[-1]` is the command payload. In `--json` mode the env-mismatch warning
5972
is its own `{"warning": …}` line, so filter parsed lines by a key the payload carries

tests/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,18 @@ def memory_fs():
208208
MemoryFileSystem.pseudo_dirs[:] = [""]
209209

210210

211+
@pytest.fixture
212+
def internal_profile(monkeypatch):
213+
"""Make the active profile read as an AssemblyAI (internal) login.
214+
215+
The sandbox flags/commands are hidden from help and rejected at the root
216+
callback for external accounts, so any test that drives `--sandbox` / a
217+
sandbox-only command must run as an employee. Patches the predicate rather
218+
than writing an email so it's independent of how a test sets up its config.
219+
"""
220+
monkeypatch.setattr("aai_cli.core.access.profile_is_internal", lambda *a, **k: True)
221+
222+
211223
@pytest.fixture(autouse=True)
212224
def tmp_config(monkeypatch, tmp_path):
213225
cfg_dir = tmp_path / "config"

tests/test_agent_cascade_show_code.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@
88

99
from __future__ import annotations
1010

11+
import pytest
1112
from typer.testing import CliRunner
1213

1314
from aai_cli.commands.agent_cascade import _exec
1415
from aai_cli.core import config
1516
from aai_cli.main import app
1617

18+
# The cascade is sandbox-only and its happy paths run under `--sandbox`, which the
19+
# root callback restricts to AssemblyAI logins — run the module as an employee.
20+
pytestmark = pytest.mark.usefixtures("internal_profile")
21+
1722
runner = CliRunner()
1823

1924

0 commit comments

Comments
 (0)