feat: session-independent auth API with security hardening#90
feat: session-independent auth API with security hardening#90beardedeagle merged 16 commits intomainfrom
Conversation
Add beam_agent_auth (public), beam_agent_auth_core (dispatch + port runner), and BeamAgent.Auth (Elixir facade) for checking, establishing, and revoking credentials across all 5 backends without a running session. Security posture: - Opaque vault_env type prevents agent-supplied env injection - sanitize_for_agent/1 strips raw_output, details, oauth_url - scrub_env/1 removes LD_PRELOAD, DYLD_INSERT_LIBRARIES, etc. - Executable safety checks (permissions, symlink resolution) - CLI path basename validation against canonical binary names - SSRF-safe base_url validation (localhost-only for OpenCode) - spawn_executable with no shell — no injection surface Includes 39 EUnit security tests covering hash_executable, sanitize_for_agent, from_vault, validate_base_url, is_localhost, resolve_symlinks, verify_executable_safety, and scrub_env. Removes rebar3_format from project_plugins — incompatible with OTP 28 -doc/-moduledoc attribute AST. erlfmt also broken. No working Erlang formatter exists for OTP 28 today.
Rewrites account_login, account_logout, and auth_status to dispatch real CLI authentication via beam_agent_auth_core instead of immediately recording inferred state. account_login now attempts beam_agent_auth_core:login/2 when the session's backend is resolvable, handling authenticated/pending/failed outcomes. Falls back to login_pending when the backend is unknown. account_logout performs best-effort CLI logout before recording logged_out in ETS. auth_status probes real CLI auth state via probe_and_cache_auth when no cached entry exists, caching the result for subsequent lookups. Updates auth_state type to include unknown status, source field (cli | inferred | unavailable), and optional details map. Adds internal helpers: resolve_session_backend, login_opts_from_params, maybe_add_provider, probe_and_cache_auth. All specs tightened to zero dialyzer warnings. Updates tests to match new behavior: unknown/unavailable defaults, login_pending for unresolvable backends.
Folds dependabot PR #89 into the auth branch.
There was a problem hiding this comment.
Pull request overview
This PR introduces a new session-independent authentication surface (beam_agent_auth / beam_agent_auth_core + Elixir facade) to check/login/logout across supported backends without requiring a running session, and rewires account login/logout/status to dispatch real CLI auth with additional security hardening.
Changes:
- Added core + public auth APIs for backend CLI/REST authentication, plus an Elixir facade.
- Updated
beam_agent_account_corebehavior and tests to reflect “unknown/unavailable” defaults and “login_pending” when backends/CLIs are not resolvable. - Updated docs + GitHub Pages site and bumped
actions/deploy-pagesto v5.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/core/beam_agent_auth_core.erl |
Implements per-backend auth dispatch, port runners, and security hardening utilities. |
src/public/beam_agent_auth.erl |
Public Erlang facade normalizing backends and delegating to auth_core. |
beam_agent_ex/lib/beam_agent/auth.ex |
Elixir facade (defdelegate) for the new auth API. |
src/core/beam_agent_account_core.erl |
Integrates session account login/logout/status with real auth_core probing + caching. |
test/core/beam_agent_auth_core_tests.erl |
New EUnit tests covering auth_core security and helpers. |
test/core/beam_agent_account_core_tests.erl |
Updates expectations for new account_core auth semantics. |
rebar.config |
Formatting-only changes. |
README.md |
Documents session-independent auth API and security posture. |
.github/workflows/docs.yml |
Bumps actions/deploy-pages v4 → v5. |
.github/pages/index.html |
Adds documentation/feature highlight for session-independent auth. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ctness
- scrub_env: strip dangerous vars from CallerEnv before merging, append
{Var, false} removals last so they always win over caller input
- collect_output: accumulate noeol chunks into line buffer to preserve
long lines (JSON) intact instead of splitting on fragment boundaries
- validate_base_url: restrict URI scheme to http/https only, reject
ftp/file/etc before the localhost check
- parse_json_output: fix spec to reflect that raw key is only present
on decode failure, not on success
- account_login: strip api_key/token/secret/password from Params before
persisting login_params in ETS
- account_logout: set source based on actual CLI outcome (cli when CLI
ran, unavailable when backend unresolvable or CLI not installed)
- probe_and_cache_auth: sanitize auth details via sanitize_for_agent/1
before caching in ETS to prevent sensitive data persistence
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Normalize cli_not_found to 2-tuple across all backends (gemini was
the only one using a 3-tuple); message moved to logger:info
- Normalize api_key_required to 2-tuple for consistent error shapes;
message moved to logger:info
- Soften vault_env opaque type docs to clarify it is a static
(Dialyzer) guarantee, not a runtime-enforced security boundary
- Keep logout/1 spec precise (Copilot suggestion to widen to
{error, term()} triggers underspecs warning in this project)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- from_vault/1: validate each entry is a {string(), string()} tuple,
error({invalid_vault_env, Vars}) on malformed input
- account_login: use beam_agent_redaction:is_sensitive/1 as the
canonical sensitive-key check instead of hardcoded key list
- validate_cli_path: strip .exe/.cmd/.bat extensions before comparing basename to canonical binary name, so Windows paths like C:\...\claude.exe pass the allowlist check - hash_executable tests: use portable_exe() helper (erl instead of sh) so tests pass on Windows where sh is not on PATH - resolve_symlinks test: gracefully skip when file:make_symlink returns enotsup or eperm (Windows without developer mode)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- resolve_cli/2: validate cli_path type explicitly, raise
{invalid_cli_path_type, ...} instead of crashing with case_clause
- strip_exe_extension: case-insensitive extension matching for Windows
where .EXE/.CMD/.BAT are common
- log_cli_result: only log exit code, not CLI output which may contain
tokens, OAuth URLs, or device codes
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…racy, docs - Add env_cli/3 helper that validates env-derived CLI paths against the canonical binary name allowlist (codex, gemini) - Map auth method from auth_core status to account source via auth_source_from_details/1 instead of hardcoding source => cli - Widen auth_state() type to include api | env | manual sources - Fix scrub_env comment to accurately describe what it strips - Fix codex auth doc table entry (--with-api-key → OPENAI_API_KEY env) - Fix index.html auth copy to mention REST-based implementations
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…sibility, docs - Clear ETS auth state to unknown on login failure instead of leaving stale login_pending (account_login/2) - Pass stored login_params through to logout so base_url/cli_path flow correctly to backend-specific logout (account_logout/1) - Add aria-hidden="true" to all decorative feature icons for screen readers - Document base_url option on login/2 and logout/2 in both Erlang and Elixir public APIs - Merge duplicate base_url entries in status/2 docs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/core/beam_agent_account_core.erl:204
- When
beam_agent_auth_core:logout/2returns{error, Reason}(other thancli_not_found), the code still recordsstatus => logged_outand returns{ok, #{status => logged_out}}. Becauseauth_status/1prefers the cached ETS entry, this can permanently report a logged-out state even though the underlying credentials were not revoked. Consider either (a) returning an error and not cachinglogged_out, or (b) caching a distinct status (e.g.unknown) / adding error details so subsequentauth_status/1calls can re-probe and reflect reality.
Source =
case resolve_session_backend(Session) of
{ok, Backend} ->
case beam_agent_auth_core:logout(Backend, LogoutOpts) of
ok ->
cli;
{error, {cli_not_found, _}} ->
unavailable;
{error, Reason} ->
logger:warning("CLI logout failed for ~p: ~tp",
[Backend, Reason]),
cli
end;
{error, _} ->
unavailable
end,
Updated = (maps:without([logged_in_at], Existing0))#{
session => Session,
status => logged_out,
source => Source,
logged_out_at => Now
},
put_auth_state(Session, Updated),
{ok, #{status => logged_out}}.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
For device/OAuth flows the login call can block for minutes. Capture the timestamp at authentication completion so logged_in_at reflects when credentials were actually established.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Re-probe transient auth states (login_pending, unknown) in auth_status/1 when the backend is resolvable, preserving cached state when it isn't - Widen CSI final-byte range in strip_ansi/1 from [A-Za-z] to [\x40-\x7E] to cover sequences ending in ~ and other valid bytes
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…fety
- account_login: derive source from auth result method instead of
hardcoding cli (opencode returns api, gemini returns manual)
- account_logout: map backend to logout source via logout_source/1
(opencode=api, gemini=manual, rest=cli)
- stream_hash/2: handle {error, Reason} from file:read/2 with
{executable_read_failed, Reason} instead of case_clause crash
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…buffer
- probe_and_cache_auth: pass stored login_params (base_url, cli_path,
timeout) through to beam_agent_auth_core:status/2 so probes target
the correct endpoint
- stream_hash: normalize error shape to #{reason => Reason} matching
compute_file_hash's #{path => Path, reason => Reason} pattern
- collect_output/4: accumulate noeol fragments as reversed chunk list
instead of quadratic ++ concatenation; flatten once on eol/exit
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ource, env msg
- from_vault/1: accept binary keys/values (Elixir strings) alongside
charlists; normalize to charlists via ensure_list/1 for open_port
- is_vault_env_entry/1: accept {binary, binary} and {list, binary} etc
- account_logout: use logout_source(Backend) on failure path too, not
hardcoded cli
- validate_env error message: clarify that open_port merges with parent
env and this check constrains only explicitly-set variables
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…r path
- with_backend/2: catch error:Reason from auth_core validation raises
and convert to {error, Reason} so public API never crashes callers
- account_login/2: wrap auth_core:login/2 in try/catch for same reason
- probe_and_cache_auth/1: wrap auth_core:status/2 in try/catch so
invalid stored login_params don't crash auth_status/1 callers
- compute_file_hash/1: catch stream_hash error and enrich with path
for consistent {executable_read_failed, #{path, reason}} shape
Summary
beam_agent_auth(public),beam_agent_auth_core(core),BeamAgent.Auth(Elixir facade) for checking, establishing, and revoking credentials across all 5 backends (claude, codex, copilot, opencode, gemini) without requiring a running sessionaccount_login,account_logout, andauth_statusnow dispatch real CLI authentication viabeam_agent_auth_coreinstead of recording inferred statevault_envtype,sanitize_for_agent/1,scrub_env/1(strips LD_PRELOAD/DYLD_INSERT_LIBRARIES), executable safety checks, SSRF-safe base_url validation,spawn_executablewith no shellactions/deploy-pagesv4→v5 (supersedes PR build(deps): bump actions/deploy-pages from 4 to 5 #89)Security posture
vault_env— onlyfrom_vault/1can constructsanitize_for_agent/1strips raw_output, details, oauth_urlscrub_env/1removes LD_PRELOAD, DYLD_INSERT_LIBRARIES, etc.validate_base_url/1enforces localhost-only (SSRF prevention)Files changed
src/core/beam_agent_auth_core.erl— new: core auth dispatch + port runner (1551 lines)src/public/beam_agent_auth.erl— new: public API facadebeam_agent_ex/lib/beam_agent/auth.ex— new: Elixir facade with defdelegatetest/core/beam_agent_auth_core_tests.erl— new: 39 security-focused EUnit testssrc/core/beam_agent_account_core.erl— rewired login/logout/status to use auth_coretest/core/beam_agent_account_core_tests.erl— updated for new auth behaviorrebar.config— reformatted by erlfmt (content unchanged except formatter removal)README.md,.github/pages/index.html— documentation parity.github/workflows/docs.yml— deploy-pages v4→v5Test plan
warnings_as_errors(verified locally)