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
10 changes: 9 additions & 1 deletion aai_cli/commands/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@ def login(

def body(state: AppState, json_mode: bool) -> None:
profile = state.resolve_profile()
env = environments.active().name
# A bare `assembly login` defaults to production rather than inheriting the
# profile's previously-stored env: signing in again must not silently re-target
# a sandbox the profile happened to be bound to. An explicit --env/--sandbox (or
# AAI_ENV) still selects the environment — and becomes the one this login binds
# the profile to. The browser OAuth + AMS flow reads environments.active(), so
# re-set the process global here, not just the stored/reported name.
env_obj = environments.resolve(state.env, None)
environments.set_active(env_obj)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

environments.set_active(env_obj) mutates process-global active environment, risking cross-request/session state leakage; avoid or gate process-global mutation (e.g., use request-scoped context) or clearly document/serialize access.

Details

✨ AI Reasoning
​The new code resolves an environment and then calls environments.set_active(env_obj), which mutates a process-global active environment. Module-level or process-global state mutations in server/CLI contexts can cause state to persist across invocations, leaking user-specific context or causing race conditions. The previous behavior only read environments.active(), so this change worsens the global-state mutation risk by writing into it. This is introduced in the current diff where the set_active call was added.

🔧 How do I fix it?
Avoid storing request-specific data in module-level variables. Use request-scoped variables or explicitly mark shared caches as intentional.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@AikidoSec ignore: This is the established, intentional pattern in this codebase, not a leak. The active environment is a process-global set once per CLI invocation (main.py calls environments.set_active(...) at startup; see aai_cli/core/environments.py and the architecture note in AGENTS.md). assembly runs as a single-shot process per command — there is no shared server request scope to leak across. The test suite already guards against cross-test bleed via the autouse reset_active_environment fixture in tests/conftest.py. The browser OAuth + AMS flow reads environments.active(), so login must re-set it to the resolved environment for the flow to target the right hosts; a request-scoped rewrite would be a repo-wide refactor against the documented design.


Generated by Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Based on your feedback, we ignored this issue because of the following reason:

This is the established, intentional pattern in this codebase, not a leak. The active environment is a process-global set once per CLI invocation (main.py calls environments.set_active(...) at startup; see aai_cli/core/environments.py and the architecture note in AGENTS.md). assembly runs as a single-shot process per command — there is no shared server request scope to leak across. The test suite already guards against cross-test bleed via the autouse reset_active_environment fixture in tests/conftest.py. The browser OAuth + AMS flow reads environments.active(), so login must re-set it to the resolved environment for the flow to target the right hosts; a request-scoped rewrite would be a repo-wide refactor against the documented design.


Generated by Claude Code

env = env_obj.name
# `api_key is not None` (not truthiness): --api-key "" combined with
# --with-api-key must report the conflict, not fall through to stdin.
mutually_exclusive(
Expand Down
7 changes: 5 additions & 2 deletions aai_cli/skills/aai-cli/references/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ All commands accept `--json`.
## `assembly login` — authenticate

Opens a browser OAuth flow and stores the resulting CLI API key in the OS
keyring, bound to the active `--env`; pass `--api-key` to authenticate
non-interactively (CI).
keyring. Login defaults to **production** — unlike other commands it does not
inherit the profile's previously-stored env, so a bare re-login never silently
re-targets a sandbox; pass `--sandbox`/`--env` (or set `AAI_ENV`) to sign in
elsewhere, and that becomes the env the profile is bound to. Pass `--api-key`
to authenticate non-interactively (CI).

Key options:

Expand Down
39 changes: 39 additions & 0 deletions tests/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,45 @@ def test_login_binds_env_to_profile(monkeypatch):
assert config.get_profile_env("default") == "sandbox000"


def test_login_defaults_to_production_ignoring_stored_sandbox_env(monkeypatch):
# A bare `assembly login` must default to production even when the profile was
# previously bound to the sandbox — re-signing-in shouldn't silently re-target it.
config.set_profile_env("default", "sandbox000")
seen = {}

def fake(*, json_mode=False):
from aai_cli.core import environments

# The browser OAuth/AMS flow reads environments.active(); capture what it sees.
seen["active"] = environments.active().name
return _login_result()

monkeypatch.setattr("aai_cli.auth.run_login_flow", fake)
result = runner.invoke(app, ["login"])
assert result.exit_code == 0
assert seen["active"] == "production" # the flow ran against prod, not the stored sandbox
assert config.get_profile_env("default") == "production" # and re-bound the profile to prod


def test_login_explicit_sandbox_still_overrides_production_default(monkeypatch):
# The production default is only the fallback: an explicit --sandbox still wins, and
# the flow runs against (and the profile binds to) the sandbox.
config.set_profile_env("default", "production")
seen = {}

def fake(*, json_mode=False):
from aai_cli.core import environments

seen["active"] = environments.active().name
return _login_result()

monkeypatch.setattr("aai_cli.auth.run_login_flow", fake)
result = runner.invoke(app, ["--sandbox", "login"])
assert result.exit_code == 0
assert seen["active"] == "sandbox000"
assert config.get_profile_env("default") == "sandbox000"


def test_sandbox_flag_is_shortcut_for_env(monkeypatch):
monkeypatch.setattr(
"aai_cli.auth.run_login_flow", lambda *, json_mode=False: _login_result("sk_x")
Expand Down
Loading