Skip to content

feat: session-independent auth API with security hardening#90

Merged
beardedeagle merged 16 commits intomainfrom
feat/backend-auth-flows
Apr 7, 2026
Merged

feat: session-independent auth API with security hardening#90
beardedeagle merged 16 commits intomainfrom
feat/backend-auth-flows

Conversation

@beardedeagle
Copy link
Copy Markdown
Owner

@beardedeagle beardedeagle commented Apr 7, 2026

Summary

  • Session-independent auth API: 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 session
  • Account-core integration: account_login, account_logout, and auth_status now dispatch real CLI authentication via beam_agent_auth_core instead of recording inferred state
  • Security hardening: opaque vault_env type, sanitize_for_agent/1, scrub_env/1 (strips LD_PRELOAD/DYLD_INSERT_LIBRARIES), executable safety checks, SSRF-safe base_url validation, spawn_executable with no shell
  • Dependabot fold: bumps actions/deploy-pages v4→v5 (supersedes PR build(deps): bump actions/deploy-pages from 4 to 5 #89)

Security posture

Layer Control
Env injection Opaque vault_env — only from_vault/1 can construct
Agent exposure sanitize_for_agent/1 strips raw_output, details, oauth_url
Port spawning scrub_env/1 removes LD_PRELOAD, DYLD_INSERT_LIBRARIES, etc.
CLI resolution Basename validated against canonical binary name per backend
OpenCode REST validate_base_url/1 enforces localhost-only (SSRF prevention)
Executable safety Permission checks, symlink resolution, no world-writable

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 facade
  • beam_agent_ex/lib/beam_agent/auth.ex — new: Elixir facade with defdelegate
  • test/core/beam_agent_auth_core_tests.erl — new: 39 security-focused EUnit tests
  • src/core/beam_agent_account_core.erl — rewired login/logout/status to use auth_core
  • test/core/beam_agent_account_core_tests.erl — updated for new auth behavior
  • rebar.config — reformatted by erlfmt (content unchanged except formatter removal)
  • README.md, .github/pages/index.html — documentation parity
  • .github/workflows/docs.yml — deploy-pages v4→v5

Test plan

  • All 2422 EUnit tests pass (verified locally)
  • Dialyzer: zero warnings (verified locally)
  • Compile: zero warnings with warnings_as_errors (verified locally)
  • 39 new security tests cover: hash_executable, sanitize_for_agent, from_vault, validate_base_url, is_localhost, resolve_symlinks, verify_executable_safety, scrub_env
  • Account-core tests updated for new auth integration behavior (login_pending when backend unresolvable, unknown/unavailable defaults)
  • CI pipeline validates all quality gates

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_core behavior 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-pages to 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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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)
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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)
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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
@beardedeagle beardedeagle requested a review from Copilot April 7, 2026 15:37
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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/2 returns {error, Reason} (other than cli_not_found), the code still records status => logged_out and returns {ok, #{status => logged_out}}. Because auth_status/1 prefers 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 caching logged_out, or (b) caching a distinct status (e.g. unknown) / adding error details so subsequent auth_status/1 calls 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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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
@beardedeagle beardedeagle merged commit a2042af into main Apr 7, 2026
9 checks passed
@beardedeagle beardedeagle deleted the feat/backend-auth-flows branch April 7, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants