From a57bfb3dcdd9a9b410c0c271338a41f7bb067f2e Mon Sep 17 00:00:00 2001 From: Yuval Date: Thu, 10 Sep 2026 22:40:13 +0300 Subject: [PATCH 01/11] feat(plugins): read the first env file holding ROGUE_API_KEY, alone (FIRE-2116) Every bridge, heartbeat, log shipper, status script, auto-updater and inline status-command loader now resolves credentials from exactly one env file: the first of /etc/rogue/env (C:\ProgramData\rogue\env), /env and ~/.rogue-env that holds ROGUE_API_KEY. Nothing is merged from the other candidates, a candidate without the key is skipped, and the chosen file's values override the process environment on sh, PowerShell and the Gemini JS loaders alike. The installers read existing credentials by the same rule. ROGUE_ENV_FILE is removed from install.sh, install.ps1 and every setup writer; no runtime loader ever honored it. Co-Authored-By: Claude Fable 5.1 --- install.ps1 | 21 +++--- install.sh | 14 ++-- plugins/antigravity/scripts/heartbeat.ps1 | 20 +++--- plugins/antigravity/scripts/heartbeat.sh | 10 +-- plugins/antigravity/scripts/hook.ps1 | 32 +++++---- plugins/antigravity/scripts/hook.sh | 23 +++--- plugins/antigravity/scripts/setup.ps1 | 2 +- plugins/antigravity/scripts/setup.sh | 8 +-- plugins/antigravity/scripts/ship-logs.ps1 | 21 +++--- plugins/antigravity/scripts/ship-logs.sh | 24 ++----- plugins/antigravity/skills/status/SKILL.md | 83 +++++++++++++--------- plugins/codex/commands/status.md | 70 ++++++++++-------- plugins/codex/scripts/heartbeat.ps1 | 19 ++--- plugins/codex/scripts/heartbeat.sh | 10 +-- plugins/codex/scripts/hook.ps1 | 32 +++++---- plugins/codex/scripts/hook.sh | 10 +-- plugins/codex/scripts/setup.ps1 | 2 +- plugins/codex/scripts/setup.sh | 9 +-- plugins/codex/scripts/ship-logs.ps1 | 21 +++--- plugins/codex/scripts/ship-logs.sh | 24 ++----- plugins/codex/scripts/warn.ps1 | 13 ++-- plugins/codex/scripts/warn.sh | 9 ++- plugins/copilot/scripts/heartbeat.ps1 | 19 ++--- plugins/copilot/scripts/heartbeat.sh | 10 +-- plugins/copilot/scripts/hook.ps1 | 32 +++++---- plugins/copilot/scripts/hook.sh | 17 +++-- plugins/copilot/scripts/setup.ps1 | 2 +- plugins/copilot/scripts/setup.sh | 8 +-- plugins/copilot/scripts/ship-logs.ps1 | 21 +++--- plugins/copilot/scripts/ship-logs.sh | 24 ++----- plugins/copilot/skills/status/SKILL.md | 64 ++++++++++------- plugins/cursor/commands/status.md | 58 ++++++++------- plugins/cursor/scripts/hook.ps1 | 41 +++++------ plugins/cursor/scripts/hook.sh | 30 +++----- plugins/cursor/scripts/setup.ps1 | 2 +- plugins/cursor/scripts/setup.sh | 2 +- plugins/cursor/scripts/ship-logs.ps1 | 21 +++--- plugins/cursor/scripts/ship-logs.sh | 24 ++----- plugins/gemini/scripts/hook.mjs | 4 +- plugins/gemini/scripts/setup.mjs | 6 +- plugins/gemini/scripts/shared.mjs | 21 +++--- plugins/gemini/scripts/ship-logs.mjs | 23 +++--- plugins/gemini/skills/status/SKILL.md | 63 +++++++++------- plugins/kiro/scripts/heartbeat.ps1 | 19 ++--- plugins/kiro/scripts/heartbeat.sh | 10 +-- plugins/kiro/scripts/hook.ps1 | 28 ++++---- plugins/kiro/scripts/hook.sh | 19 ++--- plugins/kiro/scripts/ship-logs.ps1 | 21 +++--- plugins/kiro/scripts/ship-logs.sh | 24 ++----- plugins/kiro/scripts/status.sh | 11 +-- plugins/rogue/scripts/auto-update.ps1 | 22 +++--- plugins/rogue/scripts/auto-update.sh | 15 ++-- plugins/rogue/scripts/heartbeat.ps1 | 23 +++--- plugins/rogue/scripts/heartbeat.sh | 10 +-- plugins/rogue/scripts/hook.ps1 | 41 ++++++----- plugins/rogue/scripts/hook.sh | 9 ++- plugins/rogue/scripts/setup.ps1 | 2 +- plugins/rogue/scripts/setup.sh | 7 +- plugins/rogue/scripts/ship-logs.ps1 | 21 +++--- plugins/rogue/scripts/ship-logs.sh | 24 ++----- plugins/rogue/scripts/statusline.sh | 2 +- plugins/rogue/scripts/warn.sh | 9 ++- plugins/rogue/skills/status/SKILL.md | 82 +++++++++++---------- scripts/compile-customer-plugin.sh | 4 +- scripts/compile-local-dev.sh | 4 +- scripts/shared/ship-logs.ps1 | 21 +++--- scripts/shared/ship-logs.sh | 24 ++----- 67 files changed, 764 insertions(+), 657 deletions(-) diff --git a/install.ps1 b/install.ps1 index 3d23d61..2607bb6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -69,7 +69,7 @@ $ROGUE_BASE_URL_DEFAULT = 'https://api.rogue.security' $MarketplaceName = 'rogue-marketplace' $CopilotMarketplaceName = 'rogue-copilot' $PluginName = 'rogue' -$EnvFile = if ($env:ROGUE_ENV_FILE) { $env:ROGUE_ENV_FILE } else { Join-Path $env:USERPROFILE '.rogue-env' } +$EnvFile = Join-Path $env:USERPROFILE '.rogue-env' # Merge env vars -> params (explicit params win). if (-not $ApiKey) { $ApiKey = $env:ROGUE_API_KEY } @@ -411,22 +411,23 @@ function ConvertFrom-ShellQuoted { return $sb.ToString() } -# Load existing creds from disk (same priority as the dispatcher: later wins). +# Load existing creds from disk: the first env file holding ROGUE_API_KEY, as the +# dispatcher reads it. function Load-ExistingCreds { foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not (Test-Path -LiteralPath $f)) { continue } + $vals = @{} foreach ($line in (Get-Content -LiteralPath $f -Encoding UTF8 -ErrorAction SilentlyContinue)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $k = $Matches[1] - $v = ConvertFrom-ShellQuoted $Matches[2].Trim() - switch ($k) { - 'ROGUE_API_KEY' { if (-not $script:ApiKey) { $script:ApiKey = $v } } - 'ROGUE_ACTOR_EMAIL' { if (-not $script:Email) { $script:Email = $v } } - 'ROGUE_ACTOR_NAME' { if (-not $script:Name) { $script:Name = $v } } - 'ROGUE_BASE_URL' { if (-not $script:BaseUrlExplicit) { $script:BaseUrl = $v } } - } + $vals[$Matches[1]] = ConvertFrom-ShellQuoted $Matches[2].Trim() } } + if (-not $vals['ROGUE_API_KEY']) { continue } + if (-not $script:ApiKey) { $script:ApiKey = $vals['ROGUE_API_KEY'] } + if (-not $script:Email -and $vals['ROGUE_ACTOR_EMAIL']) { $script:Email = $vals['ROGUE_ACTOR_EMAIL'] } + if (-not $script:Name -and $vals['ROGUE_ACTOR_NAME']) { $script:Name = $vals['ROGUE_ACTOR_NAME'] } + if (-not $script:BaseUrlExplicit -and $vals['ROGUE_BASE_URL']) { $script:BaseUrl = $vals['ROGUE_BASE_URL'] } + break } } Load-ExistingCreds diff --git a/install.sh b/install.sh index 1f77c33..c26306a 100755 --- a/install.sh +++ b/install.sh @@ -58,7 +58,7 @@ PLUGIN_NAME="rogue" CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" STATUSLINE_PATH="$CONFIG_DIR/hooks/rogue-statusline.sh" SETTINGS_PATH="$CONFIG_DIR/settings.json" -ENV_FILE="${ROGUE_ENV_FILE:-$HOME/.rogue-env}" +ENV_FILE="$HOME/.rogue-env" NON_INTERACTIVE="${ROGUE_NON_INTERACTIVE:-0}" # Explicit agent selection via --claude/--codex/--cursor. Empty = auto-detect all. @@ -685,9 +685,13 @@ configure_credentials() { local flag_name="${ROGUE_ACTOR_NAME:-}" local flag_base_url="$ROGUE_BASE_URL" - # Pull anything already on disk / in env into scope. - [ -r /etc/rogue/env ] && . /etc/rogue/env - [ -r "$ENV_FILE" ] && . "$ENV_FILE" + # Pull anything already on disk into scope: the first env file holding + # ROGUE_API_KEY, as the hooks read it. + for _env_file in /etc/rogue/env "$ENV_FILE"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi + done [ "$BASE_URL_EXPLICIT" = "1" ] && ROGUE_BASE_URL="$flag_base_url" @@ -826,7 +830,7 @@ write_statusline_script() { # teal bracketed label: 🟢 [Rogue Security] configured, 🔴 [Rogue Security] not. set -u for f in /etc/rogue/env "$HOME/.rogue-env"; do - [ -r "$f" ] && . "$f" + if [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f"; then . "$f"; break; fi done if [ -n "${ROGUE_API_KEY:-}" ]; then dot='🟢' diff --git a/plugins/antigravity/scripts/heartbeat.ps1 b/plugins/antigravity/scripts/heartbeat.ps1 index d22d901..aff7e80 100644 --- a/plugins/antigravity/scripts/heartbeat.ps1 +++ b/plugins/antigravity/scripts/heartbeat.ps1 @@ -112,20 +112,22 @@ function Get-BeaconLibrary { # ── credential resolution ────────────────────────────────────────────────── function Import-Credentials { $script:creds = @{} - foreach ($f in @((Join-Path $pluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { + foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', + 'ROGUE_HEARTBEAT_MIN_INTERVAL') { + $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $script:creds[$k] = $val } + } + # The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $pluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $script:creds[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - # ROGUE_HEARTBEAT_MIN_INTERVAL rides this list so a process-env value still beats - # the files, which is what makes the resolved precedence identical to - # heartbeat.sh's. - foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', - 'ROGUE_HEARTBEAT_MIN_INTERVAL') { - $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $script:creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($k in $fileVals.Keys) { $script:creds[$k] = $fileVals[$k] } + break } $script:apiKey = $script:creds['ROGUE_API_KEY'] } diff --git a/plugins/antigravity/scripts/heartbeat.sh b/plugins/antigravity/scripts/heartbeat.sh index 47b7912..5b755f6 100644 --- a/plugins/antigravity/scripts/heartbeat.sh +++ b/plugins/antigravity/scripts/heartbeat.sh @@ -39,11 +39,13 @@ locate_plugin_root() { [ -n "$PLUGIN_ROOT" ] || PLUGIN_ROOT="." } -# Same env precedence as hook.sh (later wins): bundled → MDM → per-user. load_env() { - [ -r "${PLUGIN_ROOT}/env" ] && . "${PLUGIN_ROOT}/env" - [ -r /etc/rogue/env ] && . /etc/rogue/env - [ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" + # The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi + done # Trim a trailing slash so a user-set ROGUE_BASE_URL with one doesn't yield # "//" in the composed URL (mirrors hook.ps1's .TrimEnd('/')). Guarded for # unset since this script runs under `set -u`. diff --git a/plugins/antigravity/scripts/hook.ps1 b/plugins/antigravity/scripts/hook.ps1 index e890dc4..9e5bcdb 100644 --- a/plugins/antigravity/scripts/hook.ps1 +++ b/plugins/antigravity/scripts/hook.ps1 @@ -25,9 +25,10 @@ # file), $PSCommandPath is empty — hooks.json passes the plugin root # ((Get-Location).Path) as the 2nd argument instead. # -# Credential resolution (later file wins; process env wins over all): -# 1. \env (baked into a compiled customer plugin) -# 2. C:\ProgramData\rogue\env (MDM-provisioned; mirrors /etc/rogue/env) +# Credential resolution: the first env file holding ROGUE_API_KEY is used alone, +# and its values override the process env: +# 1. C:\ProgramData\rogue\env (machine, MDM-provisioned; mirrors /etc/rogue/env) +# 2. \env (bundled into a compiled customer plugin) # 3. %USERPROFILE%\.rogue-env (user / installer-written) param([string]$EventName = '', [string]$PluginRoot = '') @@ -149,8 +150,8 @@ function Resolve-PluginRoot { # for a fleet that relocates logs by policy AND would make the log shipper and the # dispatcher disagree on the path. function Initialize-Logging { - # $Creds is the merged credential map (bundled env → MDM → per-user file, then - # process env last), so precedence is already correct by the time we read it. + # $Creds is the resolved credential map (process env, then the chosen env file + # over it), so precedence is already correct by the time we read it. # $HOME backs up USERPROFILE so this also works dot-sourced on macOS/Linux # through the ROGUE_PS_LIB_ONLY seam (tests) — without it $logFile resolves to # $null there and every line is silently dropped. @@ -243,22 +244,25 @@ function Log { } catch {} } -# ── credential resolution (later file wins; process env wins over all) ───── +# ── credential resolution ────────────────────────────────────────────────── function Import-Credentials { $script:creds = @{} - foreach ($f in @((Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { + foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_API_URL', + 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES') { + $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $script:creds[$k] = $val } + } + # The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $PluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $script:creds[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - # ROGUE_LOG_* ride the same list so a process-env value still beats the files, - # which is what makes the resolved precedence identical to hook.sh's load_env. - foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_API_URL', - 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES') { - $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $script:creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($k in $fileVals.Keys) { $script:creds[$k] = $fileVals[$k] } + break } $script:apiKey = $script:creds['ROGUE_API_KEY'] } diff --git a/plugins/antigravity/scripts/hook.sh b/plugins/antigravity/scripts/hook.sh index 79aa878..effa04d 100755 --- a/plugins/antigravity/scripts/hook.sh +++ b/plugins/antigravity/scripts/hook.sh @@ -25,9 +25,10 @@ # decision when the PowerShell handler also runs on the same invocation. # ROGUE_FORCE_UNAME overrides uname (for tests). # -# Credential resolution (later file wins; process env wins over all): -# 1. ${PLUGIN_ROOT}/env (baked into a compiled customer plugin) -# 2. /etc/rogue/env (MDM-provisioned) +# Credential resolution: the first env file holding ROGUE_API_KEY is used alone, +# and its values override the process env: +# 1. /etc/rogue/env (machine, MDM-provisioned) +# 2. ${PLUGIN_ROOT}/env (bundled into a compiled customer plugin) # 3. $HOME/.rogue-env (per-user / installer-written) # ── Shape of this file ───────────────────────────────────────────────────── @@ -66,14 +67,16 @@ locate_plugin_root() { [ -n "$PLUGIN_ROOT" ] || PLUGIN_ROOT="." } -# Env precedence (later wins): bundled → MDM → per-user. Every default derived -# from the environment is computed HERE, after the sourcing, because a user's -# `~/.rogue-env` must be able to set any of them — computing them at file scope -# would freeze the built-in default before the file that overrides it is read. +# Every default derived from the environment is computed HERE, after the +# sourcing, because the env file must be able to set any of them — computing them +# at file scope would freeze the built-in default before the file is read. load_env() { - [ -r "${PLUGIN_ROOT}/env" ] && . "${PLUGIN_ROOT}/env" - [ -r /etc/rogue/env ] && . /etc/rogue/env - [ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" + # The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi + done # Log destination — ONE FILE PER AGENT. Every Rogue plugin shares ~/.rogue, so # a machine running Antigravity + Claude Code + Cursor + … used to interleave diff --git a/plugins/antigravity/scripts/setup.ps1 b/plugins/antigravity/scripts/setup.ps1 index 334e108..10e8c8a 100644 --- a/plugins/antigravity/scripts/setup.ps1 +++ b/plugins/antigravity/scripts/setup.ps1 @@ -12,7 +12,7 @@ param( $ErrorActionPreference = 'Stop' -$EnvFile = if ($env:ROGUE_ENV_FILE) { $env:ROGUE_ENV_FILE } else { Join-Path $env:USERPROFILE '.rogue-env' } +$EnvFile = Join-Path $env:USERPROFILE '.rogue-env' . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'env-file.ps1')))) diff --git a/plugins/antigravity/scripts/setup.sh b/plugins/antigravity/scripts/setup.sh index 2292c0a..f5d24c9 100644 --- a/plugins/antigravity/scripts/setup.sh +++ b/plugins/antigravity/scripts/setup.sh @@ -8,16 +8,16 @@ set -euo pipefail # # Usage: setup.sh # -# Hooks read credentials from (in order, later wins): -# 1) ${PLUGIN_ROOT}/env (bundled defaults, for compiled customer plugins) -# 2) /etc/rogue/env (system-wide, for MDM deployments) +# Hooks read the first of these that holds ROGUE_API_KEY, alone: +# 1) /etc/rogue/env (machine, for MDM deployments) +# 2) ${PLUGIN_ROOT}/env (bundled, for compiled customer plugins) # 3) ~/.rogue-env (per-user, written by this script) API_KEY="${1:?Usage: setup.sh }" ACTOR_EMAIL="${2:-}" ACTOR_NAME="${3:-}" -ENV_FILE="${ROGUE_ENV_FILE:-$HOME/.rogue-env}" +ENV_FILE="$HOME/.rogue-env" . "$(dirname "$0")/env-file.sh" rogue_write_env_file "$ENV_FILE" \ diff --git a/plugins/antigravity/scripts/ship-logs.ps1 b/plugins/antigravity/scripts/ship-logs.ps1 index b375667..3b83519 100644 --- a/plugins/antigravity/scripts/ship-logs.ps1 +++ b/plugins/antigravity/scripts/ship-logs.ps1 @@ -320,8 +320,9 @@ function Initialize-Args { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same chain as every dispatcher (later file wins; process env wins over all): -# \env -> C:\ProgramData\rogue\env (MDM) -> %USERPROFILE%\.rogue-env +# Same rule as every dispatcher: the first trusted env file holding ROGUE_API_KEY +# is used alone, and its values override the process env: +# C:\ProgramData\rogue\env (machine, MDM) -> \env -> %USERPROFILE%\.rogue-env $SHIP_ENV_VARS = @( 'ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME', 'ROGUE_LOG_FILE', 'ROGUE_LOG_DIR', 'ROGUE_SHIP_MIN_INTERVAL', @@ -333,21 +334,25 @@ function Import-ShipEnv { if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) $resolved = @{} + foreach ($varName in $SHIP_ENV_VARS) { + $processValue = [Environment]::GetEnvironmentVariable($varName) + if ($processValue) { $resolved[$varName] = $processValue } + } $envFiles = @( - (Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', + (Join-Path $PluginRoot 'env'), (Join-Path (Get-UserHome) '.rogue-env')) foreach ($envFile in $envFiles) { if (-not $envFile -or -not (Test-Path -LiteralPath $envFile)) { continue } + $fileVals = @{} foreach ($line in (Read-RogueEnvFile $envFile)) { if ($line -match '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { - $resolved[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - foreach ($varName in $SHIP_ENV_VARS) { - $processValue = [Environment]::GetEnvironmentVariable($varName) - if ($processValue) { $resolved[$varName] = $processValue } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($varName in $fileVals.Keys) { $resolved[$varName] = $fileVals[$varName] } + break } $script:creds = $resolved } diff --git a/plugins/antigravity/scripts/ship-logs.sh b/plugins/antigravity/scripts/ship-logs.sh index ca5fbb1..ecdb7b3 100644 --- a/plugins/antigravity/scripts/ship-logs.sh +++ b/plugins/antigravity/scripts/ship-logs.sh @@ -290,26 +290,16 @@ parse_args() { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same platform-aware chain as every dispatcher (later file wins; process env -# wins over all files): -# /env -> /etc/rogue/env (MDM) -> $HOME/.rogue-env -# Process env is saved BEFORE sourcing, because `. file` overwrites it. -SHIP_ENV_VARS='ROGUE_API_KEY ROGUE_BASE_URL ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME -ROGUE_LOG_FILE ROGUE_LOG_DIR ROGUE_SHIP_MIN_INTERVAL -ROGUE_SHIP_MAX_BYTES ROGUE_SHIP_MAX_RUN_BYTES ROGUE_SHIP_MAX_LINE_BYTES -ROGUE_SHIP_ALL' - +# Same platform-aware rule as every dispatcher: the first trusted env file holding +# ROGUE_API_KEY is used alone, and its values override the process env: +# /etc/rogue/env (machine, MDM) -> /env -> $HOME/.rogue-env load_env() { [ -r "$(dirname "$0")/env-file.sh" ] || return 0 . "$(dirname "$0")/env-file.sh" - for _env_var_name in $SHIP_ENV_VARS; do - eval "_process_env_$_env_var_name=\${$_env_var_name:-}" - done - for _env_file in "$PLUGIN_ROOT/env" /etc/rogue/env "$HOME/.rogue-env"; do - rogue_source_env "$_env_file" 2>/dev/null - done - for _env_var_name in $SHIP_ENV_VARS; do - eval "[ -n \"\${_process_env_$_env_var_name:-}\" ] && $_env_var_name=\$_process_env_$_env_var_name" + for _env_file in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file" 2>/dev/null; then + . "$_env_file"; break + fi done return 0 } diff --git a/plugins/antigravity/skills/status/SKILL.md b/plugins/antigravity/skills/status/SKILL.md index 8ec1242..b15ca8b 100644 --- a/plugins/antigravity/skills/status/SKILL.md +++ b/plugins/antigravity/skills/status/SKILL.md @@ -5,7 +5,7 @@ description: Check Rogue Security AIDR connection status, active rulesets, and c # Rogue Security Status (Google Antigravity) -Check the current status of the Rogue Security AIDR integration for Google Antigravity (IDE 2.0 and the `agy` CLI). The plugin hooks source credentials from three locations in order (later wins): the plugin's bundled `env` (managed installs), `/etc/rogue/env` (MDM-provisioned), and `~/.rogue-env` (per-user setup). This command checks all three so it works for managed, MDM, and individual deployments. +Check the current status of the Rogue Security AIDR integration for Google Antigravity (IDE 2.0 and the `agy` CLI). The plugin hooks read exactly one env file: the first of `/etc/rogue/env` (MDM-provisioned), the plugin's bundled `env` (managed installs), and `~/.rogue-env` (per-user setup) that holds `ROGUE_API_KEY`. This command applies the same rule and reports which file is in use. **Pick the command variant for the user's OS.** Use the **macOS / Linux (bash)** commands by default; use the **Windows (PowerShell)** commands when the user is on native Windows — the credential files there are `C:\ProgramData\rogue\env` (MDM) and `%USERPROFILE%\.rogue-env` (per-user), and the plugin's bundled `env` lives under `%USERPROFILE%\.gemini\config\plugins\rogue`. @@ -14,28 +14,36 @@ Check the current status of the Rogue Security AIDR integration for Google Antig - macOS / Linux: ```bash PLUGIN_ENV=$(find "$HOME/.gemini" -maxdepth 5 -type f -name env -path '*rogue*' 2>/dev/null | head -1) -[ -n "$PLUGIN_ENV" ] && [ -r "$PLUGIN_ENV" ] && . "$PLUGIN_ENV" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +ROGUE_ENV_IN_USE="" +# The first env file holding ROGUE_API_KEY is used alone. +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; ROGUE_ENV_IN_USE=$f; break; } +done echo "Credential sources detected:" [ -n "$PLUGIN_ENV" ] && [ -r "$PLUGIN_ENV" ] && echo " $PLUGIN_ENV (plugin bundle)" [ -r /etc/rogue/env ] && echo " /etc/rogue/env (MDM)" [ -r "$HOME/.rogue-env" ] && echo " $HOME/.rogue-env (per-user)" +echo "In use: ${ROGUE_ENV_IN_USE:-(none holds ROGUE_API_KEY)}" [ -n "$ROGUE_API_KEY" ] && echo "API key resolved: ...${ROGUE_API_KEY: -4}" || echo "API key: not resolved" ``` - Windows (PowerShell): ```powershell -$creds = @{} $pluginEnv = Get-ChildItem "$env:USERPROFILE\.gemini\config\plugins" -Recurse -Filter env -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -like '*rogue*' } | Select-Object -First 1 -foreach ($f in @($pluginEnv.FullName, 'C:\ProgramData\rogue\env', "$env:USERPROFILE\.rogue-env")) { +$creds = @{} +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +foreach ($f in @('C:\ProgramData\rogue\env', $pluginEnv.FullName, "$env:USERPROFILE\.rogue-env")) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } - Write-Host " $f" + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $creds[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + Write-Host " in use: $f" + $creds = $fileVals + break } $key = $creds['ROGUE_API_KEY'] if ($key) { 'API key resolved: ...' + $key.Substring([Math]::Max(0,$key.Length-4)) } else { 'API key: not resolved' } @@ -50,9 +58,10 @@ Hit the status endpoint with the resolved key. This validates the key, registers - macOS / Linux: ```bash PLUGIN_ENV=$(find "$HOME/.gemini" -maxdepth 5 -type f -name env -path '*rogue*' 2>/dev/null | head -1) -[ -n "$PLUGIN_ENV" ] && [ -r "$PLUGIN_ENV" ] && . "$PLUGIN_ENV" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone. +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; } +done VF=$(find "$HOME/.gemini" -maxdepth 5 -type f -name VERSION -path '*rogue*' 2>/dev/null | head -1) VER=$(head -n1 "$VF" 2>/dev/null | tr -d ' \r\n') AGENT="antigravity_ide" @@ -90,9 +99,10 @@ Report from the JSON response (HTTP 200 = connected): organization name, running - macOS / Linux: ```bash PLUGIN_ENV=$(find "$HOME/.gemini" -maxdepth 5 -type f -name env -path '*rogue*' 2>/dev/null | head -1) -[ -n "$PLUGIN_ENV" ] && [ -r "$PLUGIN_ENV" ] && . "$PLUGIN_ENV" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone. +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; } +done curl -s -H "x-rogue-api-key: $ROGUE_API_KEY" \ "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/config" ``` @@ -111,16 +121,21 @@ Each Rogue plugin logs to its **own** file under `~/.rogue/logs/`, so this reads - macOS / Linux: ```bash -# Same precedence as the dispatcher: the env files first (system, then per-user), -# with the process environment winning over both. Read with sed, never by -# sourcing - a status command must not execute an env file. Reading only -# $ROGUE_LOG_* would report "no activity" on exactly the machines that relocate -# their logs by policy, which are the ones support is called about. +PLUGIN_ENV=$(find "$HOME/.gemini" -maxdepth 5 -type f -name env -path '*rogue*' 2>/dev/null | head -1) +# Same rule as the dispatcher: only the env file in use (the first holding +# ROGUE_API_KEY) is read, with the process environment for anything it does not +# set. Read with sed, never by sourcing - a status command must not execute an env +# file. Reading only $ROGUE_LOG_* would report "no activity" on exactly the +# machines that relocate their logs by policy, which are the ones support is +# called about. +ROGUE_ENV_IN_USE="" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { ROGUE_ENV_IN_USE=$f; break; } +done rogue_log_var() { v=$(sed -n "s/^[[:space:]]*\(export[[:space:]][[:space:]]*\)\{0,1\}$1=//p" \ - /etc/rogue/env "$HOME/.rogue-env" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") - eval "p=\${$1:-}" - [ -n "$p" ] && v=$p + "${ROGUE_ENV_IN_USE:-/dev/null}" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") + [ -n "$v" ] || eval "v=\${$1:-}" printf '%s' "$v" } log=$(rogue_log_var ROGUE_LOG_FILE) @@ -135,22 +150,26 @@ tail -n 20 "$log" 2>/dev/null || echo "(no hook log yet)" - Windows (PowerShell): ```powershell $logCfg = @{} -# Mirror the dispatcher's chain: C:\ProgramData\rogue\env (MDM) then -# %USERPROFILE%\.rogue-env, with the process environment winning over both. -# Parsed with a regex, never executed - a status command must not run an env -# file. Reading only $env: would report "no activity" on exactly the machines -# that relocate their logs by policy, which are the ones support is called about. +# Mirror the dispatcher's rule: the first of C:\ProgramData\rogue\env (MDM) and +# %USERPROFILE%\.rogue-env that holds ROGUE_API_KEY is read, with the process +# environment for anything it does not set. Parsed with a regex, never executed - +# a status command must not run an env file. Reading only $env: would report "no +# activity" on exactly the machines that relocate their logs by policy, which are +# the ones support is called about. foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { - if ($line -match '^\s*(?:export\s+)?(ROGUE_LOG_FILE|ROGUE_LOG_DIR)=(.+)$') { - $logCfg[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + $logCfg = $fileVals + break } foreach ($v in 'ROGUE_LOG_FILE','ROGUE_LOG_DIR') { - $pv = [Environment]::GetEnvironmentVariable($v) - if ($pv) { $logCfg[$v] = $pv } + if (-not $logCfg[$v]) { $pv = [Environment]::GetEnvironmentVariable($v); if ($pv) { $logCfg[$v] = $pv } } } $logPath = $logCfg['ROGUE_LOG_FILE'] if (-not $logPath) { @@ -211,7 +230,7 @@ else { $env:ROGUE_SHIP_MIN_INTERVAL = '0'; $env:ROGUE_DEBUG = '1' $env:ROGUE_SHIPPER_SCRIPT = $ship # PASS THE ROOT. On a no-argument run the shipper self-locates its plugin root to - # read \env, the FIRST file in the credential chain - and $PSCommandPath is + # read \env, a candidate in the credential chain - and $PSCommandPath is # EMPTY under [scriptblock]::Create, so it falls back to the current directory, # which is the operator's cwd and has no env file. The bundled ROGUE_BASE_URL is # then missed and identity can be absent entirely (outcome=skip reason=no-actor), diff --git a/plugins/codex/commands/status.md b/plugins/codex/commands/status.md index 8661529..3afc298 100644 --- a/plugins/codex/commands/status.md +++ b/plugins/codex/commands/status.md @@ -5,9 +5,9 @@ description: Check Rogue Security AIDR connection status, active rulesets, and c # Rogue Security Status (Codex) Check the current status of the Rogue Security AIDR integration. The plugin hooks -source credentials from three locations in order (later wins): the plugin's bundled -`env` (managed installs), `/etc/rogue/env` (MDM-provisioned), and `~/.rogue-env` -(per-user setup). +read exactly one env file: the first of `/etc/rogue/env` (MDM-provisioned), the +plugin's bundled `env` (managed installs), and `~/.rogue-env` (per-user setup) that +holds `ROGUE_API_KEY`. This command applies the same rule. The commands below are bash (macOS/Linux). **On Windows**, run the PowerShell equivalents: read the key from `%USERPROFILE%\.rogue-env` (and @@ -21,9 +21,11 @@ equivalents: read the key from `%USERPROFILE%\.rogue-env` (and ```bash cat > /tmp/rogue-source-env.sh <<'EOF' PLUGIN_ENV=$(find "$HOME/.codex/plugins" -name env -type f -path '*rogue*' 2>/dev/null | head -1) -[ -n "$PLUGIN_ENV" ] && [ -r "$PLUGIN_ENV" ] && . "$PLUGIN_ENV" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +ROGUE_ENV_IN_USE="" +# The first env file holding ROGUE_API_KEY is used alone. +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; ROGUE_ENV_IN_USE=$f; break; } +done EOF chmod +x /tmp/rogue-source-env.sh @@ -34,6 +36,7 @@ PLUGIN_ENV=$(find "$HOME/.codex/plugins" -name env -type f -path '*rogue*' 2>/de [ -r /etc/rogue/env ] && echo " /etc/rogue/env (MDM)" [ -r "$HOME/.rogue-env" ] && echo " $HOME/.rogue-env (per-user)" [ -z "$PLUGIN_ENV" ] && [ ! -r /etc/rogue/env ] && [ ! -r "$HOME/.rogue-env" ] && echo " (none)" +echo "In use: ${ROGUE_ENV_IN_USE:-(none holds ROGUE_API_KEY)}" [ -n "$ROGUE_API_KEY" ] && echo "API key resolved: ...${ROGUE_API_KEY: -4}" || echo "API key: not resolved" ``` @@ -79,16 +82,21 @@ Each Rogue plugin logs to its **own** file under `~/.rogue/logs/`, so this reads and so on. `.1` is the previous rotation, if any. ```bash -# Same precedence as the dispatcher: the env files first (system, then per-user), -# with the process environment winning over both. Read with sed, never by -# sourcing - a status command must not execute an env file. Reading only -# $ROGUE_LOG_* would report "no activity" on exactly the machines that relocate -# their logs by policy, which are the ones support is called about. +PLUGIN_ENV=$(find "$HOME/.codex/plugins" -name env -type f -path '*rogue*' 2>/dev/null | head -1) +# Same rule as the dispatcher: only the env file in use (the first holding +# ROGUE_API_KEY) is read, with the process environment for anything it does not +# set. Read with sed, never by sourcing - a status command must not execute an env +# file. Reading only $ROGUE_LOG_* would report "no activity" on exactly the +# machines that relocate their logs by policy, which are the ones support is +# called about. +ROGUE_ENV_IN_USE="" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { ROGUE_ENV_IN_USE=$f; break; } +done rogue_log_var() { v=$(sed -n "s/^[[:space:]]*\(export[[:space:]][[:space:]]*\)\{0,1\}$1=//p" \ - /etc/rogue/env "$HOME/.rogue-env" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") - eval "p=\${$1:-}" - [ -n "$p" ] && v=$p + "${ROGUE_ENV_IN_USE:-/dev/null}" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") + [ -n "$v" ] || eval "v=\${$1:-}" printf '%s' "$v" } log=$(rogue_log_var ROGUE_LOG_FILE) @@ -105,22 +113,26 @@ On Windows, resolve the same precedence before reading: ```powershell $logCfg = @{} -# Mirror the dispatcher's chain: C:\ProgramData\rogue\env (MDM) then -# %USERPROFILE%\.rogue-env, with the process environment winning over both. -# Parsed with a regex, never executed - a status command must not run an env -# file. Reading only $env: would report "no activity" on exactly the machines -# that relocate their logs by policy, which are the ones support is called about. +# Mirror the dispatcher's rule: the first of C:\ProgramData\rogue\env (MDM) and +# %USERPROFILE%\.rogue-env that holds ROGUE_API_KEY is read, with the process +# environment for anything it does not set. Parsed with a regex, never executed - +# a status command must not run an env file. Reading only $env: would report "no +# activity" on exactly the machines that relocate their logs by policy, which are +# the ones support is called about. foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { - if ($line -match '^\s*(?:export\s+)?(ROGUE_LOG_FILE|ROGUE_LOG_DIR)=(.+)$') { - $logCfg[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + $logCfg = $fileVals + break } foreach ($v in 'ROGUE_LOG_FILE','ROGUE_LOG_DIR') { - $pv = [Environment]::GetEnvironmentVariable($v) - if ($pv) { $logCfg[$v] = $pv } + if (-not $logCfg[$v]) { $pv = [Environment]::GetEnvironmentVariable($v); if ($pv) { $logCfg[$v] = $pv } } } $logPath = $logCfg['ROGUE_LOG_FILE'] if (-not $logPath) { @@ -181,7 +193,7 @@ else { $env:ROGUE_SHIP_MIN_INTERVAL = '0'; $env:ROGUE_DEBUG = '1' $env:ROGUE_SHIPPER_SCRIPT = $ship # PASS THE ROOT. On a no-argument run the shipper self-locates its plugin root to - # read \env, the FIRST file in the credential chain - and $PSCommandPath is + # read \env, a candidate in the credential chain - and $PSCommandPath is # EMPTY under [scriptblock]::Create, so it falls back to the current directory, # which is the operator's cwd and has no env file. The bundled ROGUE_BASE_URL is # then missed and identity can be absent entirely (outcome=skip reason=no-actor), @@ -209,12 +221,10 @@ only if that finds nothing. **Which copy runs matters, so report the path it prints.** On a no-argument run the shipper self-locates its plugin root from its own script path and reads -`/env` as the *first* file in the credential chain. A leftover tree -from a previous install therefore supplies credentials: a later `~/.rogue-env` -overrides the API key, but `setup.sh` writes no `ROGUE_BASE_URL` of its own, so a -stale base URL in that tree's bundled `env` would win and the upload would go to -the wrong host. (One added to `~/.rogue-env` by hand does now survive: every -writer merges rather than truncating, so setup and auto-update keep it.) Codex +`/env` as a credential candidate (after `/etc/rogue/env`). A leftover +tree from a previous install whose bundled `env` holds a key therefore supplies the +credentials alone — `~/.rogue-env` is not read at all then, and a stale base URL in +that tree would send the upload to the wrong host. Codex has no equivalent of Claude Code's install registry to disambiguate with, so the command echoes the path it chose — check it names the plugin directory `/rogue:status` reported in Step 1, and if several copies exist, remove diff --git a/plugins/codex/scripts/heartbeat.ps1 b/plugins/codex/scripts/heartbeat.ps1 index f9cb82b..060dca9 100644 --- a/plugins/codex/scripts/heartbeat.ps1 +++ b/plugins/codex/scripts/heartbeat.ps1 @@ -88,19 +88,22 @@ if (-not (Get-Command Request-RogueBeaconSlot -ErrorAction SilentlyContinue)) { # ── credential resolution ────────────────────────────────────────────────── $creds = @{} -foreach ($f in @((Join-Path $pluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_CODEX_SURFACE', + 'ROGUE_HEARTBEAT_MIN_INTERVAL') { + $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } +} +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $pluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $creds[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } -} -# ROGUE_HEARTBEAT_MIN_INTERVAL rides this list so a process-env value still beats the -# files, which is what makes the resolved precedence identical to heartbeat.sh's. -foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_CODEX_SURFACE', - 'ROGUE_HEARTBEAT_MIN_INTERVAL') { - $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break } # Resolved HERE - after the env files are parsed so they can set the interval, and diff --git a/plugins/codex/scripts/heartbeat.sh b/plugins/codex/scripts/heartbeat.sh index d66fb0e..b5fe8de 100755 --- a/plugins/codex/scripts/heartbeat.sh +++ b/plugins/codex/scripts/heartbeat.sh @@ -35,10 +35,12 @@ TRIGGER="${1:-SessionStart}" # Codex sets PLUGIN_ROOT to the installed plugin directory. PLUGIN_ROOT="${PLUGIN_ROOT:-}" -# Same env precedence as hook.sh (later wins): bundled → MDM → per-user. -[ -r "${PLUGIN_ROOT}/env" ] && . "${PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi +done # Not configured → no-op (mirrors hook.sh fail-open on missing key). [ -n "${ROGUE_API_KEY:-}" ] || exit 0 diff --git a/plugins/codex/scripts/hook.ps1 b/plugins/codex/scripts/hook.ps1 index f10ea20..78ee01b 100644 --- a/plugins/codex/scripts/hook.ps1 +++ b/plugins/codex/scripts/hook.ps1 @@ -10,9 +10,10 @@ # yield `{}` on stdout, exit 0. A Codex session must never break because Rogue # infrastructure is unavailable. # -# Credential resolution (later file wins; process env wins over all): -# 1. ${PLUGIN_ROOT}\env (baked into a compiled customer plugin) -# 2. C:\ProgramData\rogue\env (MDM-provisioned; mirrors /etc/rogue/env) +# Credential resolution: the first env file holding ROGUE_API_KEY is used alone, +# and its values override the process env: +# 1. C:\ProgramData\rogue\env (machine, MDM-provisioned; mirrors /etc/rogue/env) +# 2. ${PLUGIN_ROOT}\env (bundled into a compiled customer plugin) # 3. %USERPROFILE%\.rogue-env (user / installer-written) param([string]$EventName = '') @@ -79,8 +80,8 @@ $script:logFile = $null $script:logMaxBytes = 10485760 function Initialize-Logging { - # $Creds is the merged credential map (bundled env → MDM → per-user file, then - # process env last), so precedence is already correct by the time we read it. + # $Creds is the resolved credential map (process env, then the chosen env file + # over it), so precedence is already correct by the time we read it. # $HOME backs up USERPROFILE so this also works dot-sourced on macOS/Linux. param([hashtable]$Creds = @{}) $f = $Creds['ROGUE_LOG_FILE'] @@ -178,21 +179,24 @@ Dbg "event=$EventName" $pluginRoot = $env:PLUGIN_ROOT if (-not $pluginRoot) { try { $pluginRoot = (Get-Location).Path } catch { $pluginRoot = '.' } } -# ── credential resolution (later file wins; process env wins over all) ───── +# ── credential resolution ────────────────────────────────────────────────── $creds = @{} -foreach ($f in @((Join-Path $pluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_API_URL','ROGUE_CODEX_SURFACE', + 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES') { + $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } +} +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $pluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $creds[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } -} -# ROGUE_LOG_* ride the same list so a process-env value still beats the files, -# which is what makes the resolved precedence identical to hook.sh's. -foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_API_URL','ROGUE_CODEX_SURFACE', - 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES') { - $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break } # Logging is initialised HERE - after the credential files are parsed, so they can diff --git a/plugins/codex/scripts/hook.sh b/plugins/codex/scripts/hook.sh index 034545a..7cbd4f5 100755 --- a/plugins/codex/scripts/hook.sh +++ b/plugins/codex/scripts/hook.sh @@ -14,10 +14,12 @@ EVENT="$1" # Codex sets PLUGIN_ROOT to the installed plugin directory. PLUGIN_ROOT="${PLUGIN_ROOT:-}" -# Env precedence (later wins): bundled → MDM → per-user. -[ -r "${PLUGIN_ROOT}/env" ] && . "${PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi +done # Log destination — ONE FILE PER AGENT. Every Rogue plugin shares ~/.rogue, so a # machine running Codex + Claude Code + Cursor + … used to interleave all of them diff --git a/plugins/codex/scripts/setup.ps1 b/plugins/codex/scripts/setup.ps1 index cd2874c..5d9b736 100644 --- a/plugins/codex/scripts/setup.ps1 +++ b/plugins/codex/scripts/setup.ps1 @@ -13,7 +13,7 @@ param( $ErrorActionPreference = 'Stop' -$EnvFile = if ($env:ROGUE_ENV_FILE) { $env:ROGUE_ENV_FILE } else { Join-Path $env:USERPROFILE '.rogue-env' } +$EnvFile = Join-Path $env:USERPROFILE '.rogue-env' . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'env-file.ps1')))) diff --git a/plugins/codex/scripts/setup.sh b/plugins/codex/scripts/setup.sh index 26d3434..c18202b 100755 --- a/plugins/codex/scripts/setup.sh +++ b/plugins/codex/scripts/setup.sh @@ -9,16 +9,17 @@ set -euo pipefail # surface: codex_app | codex_cli (default codex_cli) — persisted as # ROGUE_CODEX_SURFACE so the bridge sends the right x-rogue-agent. # -# Hooks read credentials from (in order, later wins): -# 1) /etc/rogue/env (system-wide, for MDM deployments) -# 2) ~/.rogue-env (per-user, written by this script) +# Hooks read the first of these that holds ROGUE_API_KEY, alone: +# 1) /etc/rogue/env (machine, for MDM deployments) +# 2) ${PLUGIN_ROOT}/env (bundled, for compiled customer plugins) +# 3) ~/.rogue-env (per-user, written by this script) API_KEY="${1:?Usage: setup.sh [surface]}" ACTOR_EMAIL="${2:-}" ACTOR_NAME="${3:-}" SURFACE="${4:-codex_cli}" -ENV_FILE="${ROGUE_ENV_FILE:-$HOME/.rogue-env}" +ENV_FILE="$HOME/.rogue-env" . "$(dirname "$0")/env-file.sh" rogue_write_env_file "$ENV_FILE" \ diff --git a/plugins/codex/scripts/ship-logs.ps1 b/plugins/codex/scripts/ship-logs.ps1 index b375667..3b83519 100644 --- a/plugins/codex/scripts/ship-logs.ps1 +++ b/plugins/codex/scripts/ship-logs.ps1 @@ -320,8 +320,9 @@ function Initialize-Args { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same chain as every dispatcher (later file wins; process env wins over all): -# \env -> C:\ProgramData\rogue\env (MDM) -> %USERPROFILE%\.rogue-env +# Same rule as every dispatcher: the first trusted env file holding ROGUE_API_KEY +# is used alone, and its values override the process env: +# C:\ProgramData\rogue\env (machine, MDM) -> \env -> %USERPROFILE%\.rogue-env $SHIP_ENV_VARS = @( 'ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME', 'ROGUE_LOG_FILE', 'ROGUE_LOG_DIR', 'ROGUE_SHIP_MIN_INTERVAL', @@ -333,21 +334,25 @@ function Import-ShipEnv { if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) $resolved = @{} + foreach ($varName in $SHIP_ENV_VARS) { + $processValue = [Environment]::GetEnvironmentVariable($varName) + if ($processValue) { $resolved[$varName] = $processValue } + } $envFiles = @( - (Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', + (Join-Path $PluginRoot 'env'), (Join-Path (Get-UserHome) '.rogue-env')) foreach ($envFile in $envFiles) { if (-not $envFile -or -not (Test-Path -LiteralPath $envFile)) { continue } + $fileVals = @{} foreach ($line in (Read-RogueEnvFile $envFile)) { if ($line -match '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { - $resolved[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - foreach ($varName in $SHIP_ENV_VARS) { - $processValue = [Environment]::GetEnvironmentVariable($varName) - if ($processValue) { $resolved[$varName] = $processValue } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($varName in $fileVals.Keys) { $resolved[$varName] = $fileVals[$varName] } + break } $script:creds = $resolved } diff --git a/plugins/codex/scripts/ship-logs.sh b/plugins/codex/scripts/ship-logs.sh index ca5fbb1..ecdb7b3 100644 --- a/plugins/codex/scripts/ship-logs.sh +++ b/plugins/codex/scripts/ship-logs.sh @@ -290,26 +290,16 @@ parse_args() { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same platform-aware chain as every dispatcher (later file wins; process env -# wins over all files): -# /env -> /etc/rogue/env (MDM) -> $HOME/.rogue-env -# Process env is saved BEFORE sourcing, because `. file` overwrites it. -SHIP_ENV_VARS='ROGUE_API_KEY ROGUE_BASE_URL ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME -ROGUE_LOG_FILE ROGUE_LOG_DIR ROGUE_SHIP_MIN_INTERVAL -ROGUE_SHIP_MAX_BYTES ROGUE_SHIP_MAX_RUN_BYTES ROGUE_SHIP_MAX_LINE_BYTES -ROGUE_SHIP_ALL' - +# Same platform-aware rule as every dispatcher: the first trusted env file holding +# ROGUE_API_KEY is used alone, and its values override the process env: +# /etc/rogue/env (machine, MDM) -> /env -> $HOME/.rogue-env load_env() { [ -r "$(dirname "$0")/env-file.sh" ] || return 0 . "$(dirname "$0")/env-file.sh" - for _env_var_name in $SHIP_ENV_VARS; do - eval "_process_env_$_env_var_name=\${$_env_var_name:-}" - done - for _env_file in "$PLUGIN_ROOT/env" /etc/rogue/env "$HOME/.rogue-env"; do - rogue_source_env "$_env_file" 2>/dev/null - done - for _env_var_name in $SHIP_ENV_VARS; do - eval "[ -n \"\${_process_env_$_env_var_name:-}\" ] && $_env_var_name=\$_process_env_$_env_var_name" + for _env_file in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file" 2>/dev/null; then + . "$_env_file"; break + fi done return 0 } diff --git a/plugins/codex/scripts/warn.ps1 b/plugins/codex/scripts/warn.ps1 index 32a667a..27f5af1 100644 --- a/plugins/codex/scripts/warn.ps1 +++ b/plugins/codex/scripts/warn.ps1 @@ -6,19 +6,18 @@ if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { exit 0 } $pluginRoot = $env:PLUGIN_ROOT -# Mirror the real resolution order (later file wins; process env wins over all), -# and treat a blank final value as unconfigured — a non-empty earlier value must -# not be masked by an empty later assignment, and vice versa. +# Mirror the real resolution order: the first env file holding ROGUE_API_KEY is +# used alone (machine, bundled, user), and it overrides the process env. $key = '' -foreach ($f in @((Join-Path $pluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $pluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if ($f -and (Test-Path -LiteralPath $f)) { foreach ($line in (Get-Content -LiteralPath $f)) { - if ($line -match '^\s*(?:export\s+)?ROGUE_API_KEY=(.*)$') { $key = $Matches[1].Trim().Trim("'").Trim('"') } + if ($line -match '^\s*(?:export\s+)?ROGUE_API_KEY=(.+)$') { $key = $Matches[1].Trim().Trim("'").Trim('"') } } } + if ($key) { break } } -$procKey = [Environment]::GetEnvironmentVariable('ROGUE_API_KEY') -if ($procKey) { $key = $procKey } +if (-not $key) { $key = [Environment]::GetEnvironmentVariable('ROGUE_API_KEY') } if (-not $key) { [Console]::Out.Write('{"systemMessage": "[Rogue Security] Not configured. Run /rogue:setup to connect your API key."}') diff --git a/plugins/codex/scripts/warn.sh b/plugins/codex/scripts/warn.sh index 6283df4..492251b 100755 --- a/plugins/codex/scripts/warn.sh +++ b/plugins/codex/scripts/warn.sh @@ -4,8 +4,11 @@ # Codex sets PLUGIN_ROOT to the installed plugin directory. PLUGIN_ROOT="${PLUGIN_ROOT:-}" -[ -r "${PLUGIN_ROOT}/env" ] && . "${PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi +done [ -n "${ROGUE_API_KEY:-}" ] || printf '{"systemMessage": "[Rogue Security] Not configured. Run /rogue:setup to connect your API key."}' diff --git a/plugins/copilot/scripts/heartbeat.ps1 b/plugins/copilot/scripts/heartbeat.ps1 index e0a6c0f..222c91f 100644 --- a/plugins/copilot/scripts/heartbeat.ps1 +++ b/plugins/copilot/scripts/heartbeat.ps1 @@ -90,19 +90,22 @@ if (-not (Get-Command Request-RogueBeaconSlot -ErrorAction SilentlyContinue)) { # ── credential resolution ────────────────────────────────────────────────── $creds = @{} -foreach ($f in @((Join-Path $pluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', + 'ROGUE_HEARTBEAT_MIN_INTERVAL') { + $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } +} +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $pluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $creds[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } -} -# ROGUE_HEARTBEAT_MIN_INTERVAL rides this list so a process-env value still beats the -# files, which is what makes the resolved precedence identical to heartbeat.sh's. -foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', - 'ROGUE_HEARTBEAT_MIN_INTERVAL') { - $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break } # Resolved HERE - after the env files are parsed so they can set the interval, and diff --git a/plugins/copilot/scripts/heartbeat.sh b/plugins/copilot/scripts/heartbeat.sh index 88b46fe..80758a8 100755 --- a/plugins/copilot/scripts/heartbeat.sh +++ b/plugins/copilot/scripts/heartbeat.sh @@ -34,10 +34,12 @@ TRIGGER="${1:-sessionStart}" PLUGIN_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd)" [ -n "$PLUGIN_ROOT" ] || PLUGIN_ROOT="${COPILOT_PLUGIN_ROOT:-.}" -# Same env precedence as hook.sh (later wins): bundled → MDM → per-user. -[ -r "${PLUGIN_ROOT}/env" ] && . "${PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi +done # Not configured → no-op (mirrors hook.sh fail-open on missing key). [ -n "${ROGUE_API_KEY:-}" ] || exit 0 diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index ea88592..f63eef1 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -21,9 +21,10 @@ # file), $PSCommandPath is empty — hooks.json passes the plugin root as the 2nd # argument. # -# Credential resolution (later file wins; process env wins over all): -# 1. ${PLUGIN_ROOT}\env (baked into a compiled customer plugin) -# 2. C:\ProgramData\rogue\env (MDM-provisioned; mirrors /etc/rogue/env) +# Credential resolution: the first env file holding ROGUE_API_KEY is used alone, +# and its values override the process env: +# 1. C:\ProgramData\rogue\env (machine, MDM-provisioned; mirrors /etc/rogue/env) +# 2. ${PLUGIN_ROOT}\env (bundled into a compiled customer plugin) # 3. %USERPROFILE%\.rogue-env (user / installer-written) param([string]$EventName = '', [string]$PluginRoot = '') @@ -93,8 +94,8 @@ $script:logFile = $null $script:logMaxBytes = 10485760 function Initialize-Logging { - # $Creds is the merged credential map (bundled env → MDM → per-user file, then - # process env last), so precedence is already correct by the time we read it. + # $Creds is the resolved credential map (process env, then the chosen env file + # over it), so precedence is already correct by the time we read it. # $HOME backs up USERPROFILE so this also works dot-sourced on macOS/Linux. param([hashtable]$Creds = @{}) $f = $Creds['ROGUE_LOG_FILE'] @@ -276,21 +277,24 @@ Dbg "event=$EventName" if (-not $PluginRoot) { $PluginRoot = $env:COPILOT_PLUGIN_ROOT } if (-not $PluginRoot) { try { $PluginRoot = (Get-Location).Path } catch { $PluginRoot = '.' } } -# ── credential resolution (later file wins; process env wins over all) ───── +# ── credential resolution ────────────────────────────────────────────────── $creds = @{} -foreach ($f in @((Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_API_URL', + 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES') { + $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } +} +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $PluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $creds[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } -} -# ROGUE_LOG_* ride the same list so a process-env value still beats the files, -# which is what makes the resolved precedence identical to hook.sh's. -foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_API_URL', - 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES') { - $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break } # Logging is initialised HERE - after the credential files are parsed, so they can diff --git a/plugins/copilot/scripts/hook.sh b/plugins/copilot/scripts/hook.sh index 5b995b6..1322486 100755 --- a/plugins/copilot/scripts/hook.sh +++ b/plugins/copilot/scripts/hook.sh @@ -27,9 +27,10 @@ # empty body). Never `set -e`; never let curl propagate a non-zero exit. A block # is carried in the relayed JSON body on stdout, never via the exit code. # -# Credential resolution (later file wins; process env wins over all): -# 1. ${PLUGIN_ROOT}/env (baked into a compiled customer plugin) -# 2. /etc/rogue/env (MDM-provisioned) +# Credential resolution: the first env file holding ROGUE_API_KEY is used alone, +# and its values override the process env: +# 1. /etc/rogue/env (machine, MDM-provisioned) +# 2. ${PLUGIN_ROOT}/env (bundled into a compiled customer plugin) # 3. $HOME/.rogue-env (per-user / installer-written) EVENT="$1" @@ -39,10 +40,12 @@ EVENT="$1" PLUGIN_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd)" [ -n "$PLUGIN_ROOT" ] || PLUGIN_ROOT="${COPILOT_PLUGIN_ROOT:-${PLUGIN_ROOT:-.}}" -# Env precedence (later wins): bundled → MDM → per-user. -[ -r "${PLUGIN_ROOT}/env" ] && . "${PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi +done # Log destination — ONE FILE PER AGENT. Every Rogue plugin shares ~/.rogue, so a # machine running Copilot CLI + Claude Code + Cursor + … used to interleave all of diff --git a/plugins/copilot/scripts/setup.ps1 b/plugins/copilot/scripts/setup.ps1 index 558e2fd..1e99259 100644 --- a/plugins/copilot/scripts/setup.ps1 +++ b/plugins/copilot/scripts/setup.ps1 @@ -12,7 +12,7 @@ param( $ErrorActionPreference = 'Stop' -$EnvFile = if ($env:ROGUE_ENV_FILE) { $env:ROGUE_ENV_FILE } else { Join-Path $env:USERPROFILE '.rogue-env' } +$EnvFile = Join-Path $env:USERPROFILE '.rogue-env' . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'env-file.ps1')))) diff --git a/plugins/copilot/scripts/setup.sh b/plugins/copilot/scripts/setup.sh index 179ebfb..2d41aa2 100755 --- a/plugins/copilot/scripts/setup.sh +++ b/plugins/copilot/scripts/setup.sh @@ -8,16 +8,16 @@ set -euo pipefail # # Usage: setup.sh # -# Hooks read credentials from (in order, later wins): -# 1) ${PLUGIN_ROOT}/env (bundled defaults, for compiled customer plugins) -# 2) /etc/rogue/env (system-wide, for MDM deployments) +# Hooks read the first of these that holds ROGUE_API_KEY, alone: +# 1) /etc/rogue/env (machine, for MDM deployments) +# 2) ${PLUGIN_ROOT}/env (bundled, for compiled customer plugins) # 3) ~/.rogue-env (per-user, written by this script) API_KEY="${1:?Usage: setup.sh }" ACTOR_EMAIL="${2:-}" ACTOR_NAME="${3:-}" -ENV_FILE="${ROGUE_ENV_FILE:-$HOME/.rogue-env}" +ENV_FILE="$HOME/.rogue-env" . "$(dirname "$0")/env-file.sh" rogue_write_env_file "$ENV_FILE" \ diff --git a/plugins/copilot/scripts/ship-logs.ps1 b/plugins/copilot/scripts/ship-logs.ps1 index b375667..3b83519 100644 --- a/plugins/copilot/scripts/ship-logs.ps1 +++ b/plugins/copilot/scripts/ship-logs.ps1 @@ -320,8 +320,9 @@ function Initialize-Args { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same chain as every dispatcher (later file wins; process env wins over all): -# \env -> C:\ProgramData\rogue\env (MDM) -> %USERPROFILE%\.rogue-env +# Same rule as every dispatcher: the first trusted env file holding ROGUE_API_KEY +# is used alone, and its values override the process env: +# C:\ProgramData\rogue\env (machine, MDM) -> \env -> %USERPROFILE%\.rogue-env $SHIP_ENV_VARS = @( 'ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME', 'ROGUE_LOG_FILE', 'ROGUE_LOG_DIR', 'ROGUE_SHIP_MIN_INTERVAL', @@ -333,21 +334,25 @@ function Import-ShipEnv { if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) $resolved = @{} + foreach ($varName in $SHIP_ENV_VARS) { + $processValue = [Environment]::GetEnvironmentVariable($varName) + if ($processValue) { $resolved[$varName] = $processValue } + } $envFiles = @( - (Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', + (Join-Path $PluginRoot 'env'), (Join-Path (Get-UserHome) '.rogue-env')) foreach ($envFile in $envFiles) { if (-not $envFile -or -not (Test-Path -LiteralPath $envFile)) { continue } + $fileVals = @{} foreach ($line in (Read-RogueEnvFile $envFile)) { if ($line -match '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { - $resolved[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - foreach ($varName in $SHIP_ENV_VARS) { - $processValue = [Environment]::GetEnvironmentVariable($varName) - if ($processValue) { $resolved[$varName] = $processValue } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($varName in $fileVals.Keys) { $resolved[$varName] = $fileVals[$varName] } + break } $script:creds = $resolved } diff --git a/plugins/copilot/scripts/ship-logs.sh b/plugins/copilot/scripts/ship-logs.sh index ca5fbb1..ecdb7b3 100644 --- a/plugins/copilot/scripts/ship-logs.sh +++ b/plugins/copilot/scripts/ship-logs.sh @@ -290,26 +290,16 @@ parse_args() { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same platform-aware chain as every dispatcher (later file wins; process env -# wins over all files): -# /env -> /etc/rogue/env (MDM) -> $HOME/.rogue-env -# Process env is saved BEFORE sourcing, because `. file` overwrites it. -SHIP_ENV_VARS='ROGUE_API_KEY ROGUE_BASE_URL ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME -ROGUE_LOG_FILE ROGUE_LOG_DIR ROGUE_SHIP_MIN_INTERVAL -ROGUE_SHIP_MAX_BYTES ROGUE_SHIP_MAX_RUN_BYTES ROGUE_SHIP_MAX_LINE_BYTES -ROGUE_SHIP_ALL' - +# Same platform-aware rule as every dispatcher: the first trusted env file holding +# ROGUE_API_KEY is used alone, and its values override the process env: +# /etc/rogue/env (machine, MDM) -> /env -> $HOME/.rogue-env load_env() { [ -r "$(dirname "$0")/env-file.sh" ] || return 0 . "$(dirname "$0")/env-file.sh" - for _env_var_name in $SHIP_ENV_VARS; do - eval "_process_env_$_env_var_name=\${$_env_var_name:-}" - done - for _env_file in "$PLUGIN_ROOT/env" /etc/rogue/env "$HOME/.rogue-env"; do - rogue_source_env "$_env_file" 2>/dev/null - done - for _env_var_name in $SHIP_ENV_VARS; do - eval "[ -n \"\${_process_env_$_env_var_name:-}\" ] && $_env_var_name=\$_process_env_$_env_var_name" + for _env_file in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file" 2>/dev/null; then + . "$_env_file"; break + fi done return 0 } diff --git a/plugins/copilot/skills/status/SKILL.md b/plugins/copilot/skills/status/SKILL.md index 95df291..ea970bd 100644 --- a/plugins/copilot/skills/status/SKILL.md +++ b/plugins/copilot/skills/status/SKILL.md @@ -6,9 +6,9 @@ description: Check Rogue Security AIDR connection status, active rulesets, and c # Rogue Security Status (GitHub Copilot CLI) Check the current status of the Rogue Security AIDR integration. The plugin hooks -source credentials from three locations in order (later wins): the plugin's bundled -`env` (managed installs), `/etc/rogue/env` (MDM-provisioned), and `~/.rogue-env` -(per-user setup). +read exactly one env file: the first of `/etc/rogue/env` (MDM-provisioned), the +plugin's bundled `env` (managed installs), and `~/.rogue-env` (per-user setup) that +holds `ROGUE_API_KEY`. This command applies the same rule. The commands below are bash (macOS/Linux). **On Windows**, run the PowerShell equivalents: read the key from `%USERPROFILE%\.rogue-env` (and @@ -19,12 +19,16 @@ equivalents: read the key from `%USERPROFILE%\.rogue-env` (and ## Step 1: Source credentials and report what's found ```bash -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +ROGUE_ENV_IN_USE="" +# The first env file holding ROGUE_API_KEY is used alone. +for f in /etc/rogue/env "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; ROGUE_ENV_IN_USE=$f; break; } +done echo "Credential sources detected:" [ -r /etc/rogue/env ] && echo " /etc/rogue/env (MDM)" [ -r "$HOME/.rogue-env" ] && echo " $HOME/.rogue-env (per-user)" [ ! -r /etc/rogue/env ] && [ ! -r "$HOME/.rogue-env" ] && echo " (none)" +echo "In use: ${ROGUE_ENV_IN_USE:-(none holds ROGUE_API_KEY)}" [ -n "$ROGUE_API_KEY" ] && echo "API key resolved: ...${ROGUE_API_KEY: -4}" || echo "API key: not resolved" [ "${ROGUE_IDE_ALERT:-1}" = "0" ] && echo "ROGUE_IDE_ALERT=0 (JetBrains blocked-prompt alert disabled)" ``` @@ -39,8 +43,7 @@ Remove the line from `~/.rogue-env` to get the reason back. ## Step 2: Test connection + register heartbeat ```bash -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +for f in /etc/rogue/env "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; }; done esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } PJ="$HOME/.copilot/installed-plugins/rogue-copilot/rogue/plugin.json" VER=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[0-9][^"]*"' "$PJ" 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') @@ -65,8 +68,7 @@ is invalid; no response → check network reachability to `api.rogue.security`. ## Step 3: Fetch configuration ```bash -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +for f in /etc/rogue/env "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; }; done curl -s -H "x-rogue-api-key: $ROGUE_API_KEY" \ "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/config" ``` @@ -82,16 +84,20 @@ Each Rogue plugin logs to its **own** file under `~/.rogue/logs/`, so this reads `cursor.log`, and so on. `.1` is the previous rotation, if any. ```bash -# Same precedence as the dispatcher: the env files first (system, then per-user), -# with the process environment winning over both. Read with sed, never by -# sourcing - a status command must not execute an env file. Reading only -# $ROGUE_LOG_* would report "no activity" on exactly the machines that relocate -# their logs by policy, which are the ones support is called about. +# Same rule as the dispatcher: only the env file in use (the first holding +# ROGUE_API_KEY) is read, with the process environment for anything it does not +# set. Read with sed, never by sourcing - a status command must not execute an env +# file. Reading only $ROGUE_LOG_* would report "no activity" on exactly the +# machines that relocate their logs by policy, which are the ones support is +# called about. +ROGUE_ENV_IN_USE="" +for f in /etc/rogue/env "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { ROGUE_ENV_IN_USE=$f; break; } +done rogue_log_var() { v=$(sed -n "s/^[[:space:]]*\(export[[:space:]][[:space:]]*\)\{0,1\}$1=//p" \ - /etc/rogue/env "$HOME/.rogue-env" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") - eval "p=\${$1:-}" - [ -n "$p" ] && v=$p + "${ROGUE_ENV_IN_USE:-/dev/null}" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") + [ -n "$v" ] || eval "v=\${$1:-}" printf '%s' "$v" } log=$(rogue_log_var ROGUE_LOG_FILE) @@ -108,22 +114,26 @@ On Windows, resolve the same precedence before reading: ```powershell $logCfg = @{} -# Mirror the dispatcher's chain: C:\ProgramData\rogue\env (MDM) then -# %USERPROFILE%\.rogue-env, with the process environment winning over both. -# Parsed with a regex, never executed - a status command must not run an env -# file. Reading only $env: would report "no activity" on exactly the machines -# that relocate their logs by policy, which are the ones support is called about. +# Mirror the dispatcher's rule: the first of C:\ProgramData\rogue\env (MDM) and +# %USERPROFILE%\.rogue-env that holds ROGUE_API_KEY is read, with the process +# environment for anything it does not set. Parsed with a regex, never executed - +# a status command must not run an env file. Reading only $env: would report "no +# activity" on exactly the machines that relocate their logs by policy, which are +# the ones support is called about. foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { - if ($line -match '^\s*(?:export\s+)?(ROGUE_LOG_FILE|ROGUE_LOG_DIR)=(.+)$') { - $logCfg[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + $logCfg = $fileVals + break } foreach ($v in 'ROGUE_LOG_FILE','ROGUE_LOG_DIR') { - $pv = [Environment]::GetEnvironmentVariable($v) - if ($pv) { $logCfg[$v] = $pv } + if (-not $logCfg[$v]) { $pv = [Environment]::GetEnvironmentVariable($v); if ($pv) { $logCfg[$v] = $pv } } } $logPath = $logCfg['ROGUE_LOG_FILE'] if (-not $logPath) { @@ -160,7 +170,7 @@ $root = Join-Path $env:USERPROFILE '.copilot\installed-plugins\rogue-copilot\rog $env:ROGUE_SHIP_MIN_INTERVAL = '0'; $env:ROGUE_DEBUG = '1' $env:ROGUE_SHIPPER_SCRIPT = Join-Path $root 'scripts\ship-logs.ps1' # PASS THE ROOT. On a no-argument run the shipper self-locates its plugin root to -# read \env, the FIRST file in the credential chain - and $PSCommandPath is +# read \env, a candidate in the credential chain - and $PSCommandPath is # EMPTY under [scriptblock]::Create, so it falls back to the current directory, # which is the operator's cwd and has no env file. The bundled ROGUE_BASE_URL is # then missed and identity can be absent entirely (outcome=skip reason=no-actor), diff --git a/plugins/cursor/commands/status.md b/plugins/cursor/commands/status.md index 5533036..da88059 100644 --- a/plugins/cursor/commands/status.md +++ b/plugins/cursor/commands/status.md @@ -5,13 +5,15 @@ description: Check Rogue Security AIDR connection, active rulesets, and configur # Rogue Security Status -Verify the current Rogue Security integration. Sources credentials in order: `/etc/rogue/env` (MDM), `~/.rogue-env` (per-user). +Verify the current Rogue Security integration. Reads one env file: the first of `/etc/rogue/env` (MDM) and `~/.rogue-env` (per-user) that holds `ROGUE_API_KEY`. ## Step 1: Source credentials and report what was found ```bash -[ -r /etc/rogue/env ] && . /etc/rogue/env && echo " /etc/rogue/env (MDM)" -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" && echo " $HOME/.rogue-env (per-user)" +# The first env file holding ROGUE_API_KEY is used alone. +for f in /etc/rogue/env "$HOME/.rogue-env"; do + [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; echo " in use: $f"; break; } +done [ -n "$ROGUE_API_KEY" ] && echo "API key resolved: ...${ROGUE_API_KEY: -4}" || { echo "API key: not resolved"; } ``` @@ -20,7 +22,7 @@ If `ROGUE_API_KEY` is empty, stop and tell the user to run `/rogue:setup`. ## Step 2: Ping the API ```bash -. "$HOME/.rogue-env" 2>/dev/null; [ -r /etc/rogue/env ] && . /etc/rogue/env +for f in /etc/rogue/env "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; }; done curl -s -w "\n%{http_code}" -H "x-rogue-api-key: $ROGUE_API_KEY" \ "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/ping" ``` @@ -28,7 +30,7 @@ curl -s -w "\n%{http_code}" -H "x-rogue-api-key: $ROGUE_API_KEY" \ ## Step 3: Fetch active config ```bash -. "$HOME/.rogue-env" 2>/dev/null; [ -r /etc/rogue/env ] && . /etc/rogue/env +for f in /etc/rogue/env "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; }; done curl -s -H "x-rogue-api-key: $ROGUE_API_KEY" \ "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/config" ``` @@ -38,20 +40,24 @@ Parse the JSON and show: mode (enforce/monitor), fail-open setting, active rules ## Step 4: Show identity + recent hook activity ```bash -. "$HOME/.rogue-env" 2>/dev/null +for f in /etc/rogue/env "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; }; done echo "Actor email: ${ROGUE_ACTOR_EMAIL:-(unset)}" echo "Actor name: ${ROGUE_ACTOR_NAME:-(unset)}" echo "--- recent hook activity ---" -# Same precedence as the dispatcher: the env files first (system, then per-user), -# with the process environment winning over both. Read with sed, never by -# sourcing - a status command must not execute an env file. Reading only -# $ROGUE_LOG_* would report "no activity" on exactly the machines that relocate -# their logs by policy, which are the ones support is called about. +# Same rule as the dispatcher: only the env file in use (the first holding +# ROGUE_API_KEY) is read, with the process environment for anything it does not +# set. Read with sed, never by sourcing - a status command must not execute an env +# file. Reading only $ROGUE_LOG_* would report "no activity" on exactly the +# machines that relocate their logs by policy, which are the ones support is +# called about. +ROGUE_ENV_IN_USE="" +for f in /etc/rogue/env "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { ROGUE_ENV_IN_USE=$f; break; } +done rogue_log_var() { v=$(sed -n "s/^[[:space:]]*\(export[[:space:]][[:space:]]*\)\{0,1\}$1=//p" \ - /etc/rogue/env "$HOME/.rogue-env" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") - eval "p=\${$1:-}" - [ -n "$p" ] && v=$p + "${ROGUE_ENV_IN_USE:-/dev/null}" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") + [ -n "$v" ] || eval "v=\${$1:-}" printf '%s' "$v" } log=$(rogue_log_var ROGUE_LOG_FILE) @@ -68,22 +74,26 @@ On Windows, resolve the same precedence before reading: ```powershell $logCfg = @{} -# Mirror the dispatcher's chain: C:\ProgramData\rogue\env (MDM) then -# %USERPROFILE%\.rogue-env, with the process environment winning over both. -# Parsed with a regex, never executed - a status command must not run an env -# file. Reading only $env: would report "no activity" on exactly the machines -# that relocate their logs by policy, which are the ones support is called about. +# Mirror the dispatcher's rule: the first of C:\ProgramData\rogue\env (MDM) and +# %USERPROFILE%\.rogue-env that holds ROGUE_API_KEY is read, with the process +# environment for anything it does not set. Parsed with a regex, never executed - +# a status command must not run an env file. Reading only $env: would report "no +# activity" on exactly the machines that relocate their logs by policy, which are +# the ones support is called about. foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { - if ($line -match '^\s*(?:export\s+)?(ROGUE_LOG_FILE|ROGUE_LOG_DIR)=(.+)$') { - $logCfg[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + $logCfg = $fileVals + break } foreach ($v in 'ROGUE_LOG_FILE','ROGUE_LOG_DIR') { - $pv = [Environment]::GetEnvironmentVariable($v) - if ($pv) { $logCfg[$v] = $pv } + if (-not $logCfg[$v]) { $pv = [Environment]::GetEnvironmentVariable($v); if ($pv) { $logCfg[$v] = $pv } } } $logPath = $logCfg['ROGUE_LOG_FILE'] if (-not $logPath) { @@ -128,7 +138,7 @@ if (-not $root) { $root = Join-Path $env:USERPROFILE '.cursor\plugins\local\rogu $env:ROGUE_SHIP_MIN_INTERVAL = '0'; $env:ROGUE_DEBUG = '1' $env:ROGUE_SHIPPER_SCRIPT = Join-Path $root 'scripts\ship-logs.ps1' # PASS THE ROOT. On a no-argument run the shipper self-locates its plugin root to -# read \env, the FIRST file in the credential chain - and $PSCommandPath is +# read \env, a candidate in the credential chain - and $PSCommandPath is # EMPTY under [scriptblock]::Create, so it falls back to the current directory, # which is the operator's cwd and has no env file. The bundled ROGUE_BASE_URL is # then missed and identity can be absent entirely (outcome=skip reason=no-actor), diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index 0742223..444bbcb 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -32,10 +32,10 @@ # Logs every invocation to $env:ROGUE_LOG_FILE (default # %USERPROFILE%\.rogue\logs\cursor.log), mirroring hook.sh. # -# Credential resolution (later file wins; process env wins over all), the -# Windows analogue of hook.sh's search: -# 1. ${CURSOR_PLUGIN_ROOT}\env (baked into a compiled customer plugin) -# 2. C:\ProgramData\rogue\env (MDM-provisioned; mirrors /etc/rogue/env) +# Credential resolution, the Windows analogue of hook.sh's search: the first env +# file holding ROGUE_API_KEY is used alone, and its values override the process env: +# 1. C:\ProgramData\rogue\env (machine, MDM-provisioned; mirrors /etc/rogue/env) +# 2. ${CURSOR_PLUGIN_ROOT}\env (bundled into a compiled customer plugin) # 3. %USERPROFILE%\.rogue-env (user / installer-written) param([string]$EventName = '') @@ -158,8 +158,8 @@ $script:logFile = $null $script:logMaxBytes = 10485760 function Initialize-Logging { - # $Creds is the merged credential map (bundled env → MDM → per-user file, then - # process env last), so precedence is already correct by the time we read it. + # $Creds is the resolved credential map (process env, then the chosen env file + # over it), so precedence is already correct by the time we read it. # $HOME backs up USERPROFILE so this also works dot-sourced on macOS/Linux. param([hashtable]$Creds = @{}) $f = $Creds['ROGUE_LOG_FILE'] @@ -754,38 +754,39 @@ if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { Write-Raw '{}' if (-not $EventName) { Dbg "no event name -> {}"; Write-Raw '{}'; exit 0 } Dbg "event=$EventName" -# ── credential resolution (later file wins; process env wins over all) ───── +# ── credential resolution ────────────────────────────────────────────────── $creds = @{} $pluginRoot = $env:CURSOR_PLUGIN_ROOT if (-not $pluginRoot) { try { $pluginRoot = (Get-Location).Path } catch { $pluginRoot = '.' } } Dbg "pluginRoot=$pluginRoot" +foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', + 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES', + 'ROGUE_HEARTBEAT_MIN_INTERVAL') { + $val = [Environment]::GetEnvironmentVariable($k) + if ($val) { $creds[$k] = $val } +} +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. $credFiles = @( - (Join-Path $pluginRoot 'env'), 'C:\ProgramData\rogue\env', + (Join-Path $pluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env') ) foreach ($f in $credFiles) { if (-not $f) { continue } if (-not (Test-Path -LiteralPath $f)) { Dbg "cred file absent: $f"; continue } - Dbg "cred file found: $f" + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $k = $Matches[1] # Decode shell quoting/escaping so the value round-trips with the # `source`-based parse in hook.sh (mirrors shlex.split). - $v = ConvertFrom-ShellQuoted ($Matches[2].Trim()) - $creds[$k] = $v + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } -} -# ROGUE_LOG_* ride the same list so a process-env value still beats the files, -# which is what makes the resolved precedence identical to hook.sh's. -foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', - 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES', - 'ROGUE_HEARTBEAT_MIN_INTERVAL') { - $val = [Environment]::GetEnvironmentVariable($k) - if ($val) { $creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { Dbg "cred file skipped: $f"; continue } + Dbg "cred file in use: $f" + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break } # Logging is initialised HERE - after the credential files are parsed, so they can diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index daab093..45ea784 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -38,9 +38,10 @@ # # Logs every invocation to $ROGUE_LOG_FILE (default ~/.rogue/logs/cursor.log). # -# Credential resolution (later file wins; process env wins over all): -# 1. ${CURSOR_PLUGIN_ROOT}/env (baked into a compiled customer plugin) -# 2. /etc/rogue/env (MDM-provisioned) +# Credential resolution: the first env file holding ROGUE_API_KEY is used alone, +# and its values override the process env: +# 1. /etc/rogue/env (machine, MDM-provisioned) +# 2. ${CURSOR_PLUGIN_ROOT}/env (bundled into a compiled customer plugin) # 3. ~/.rogue-env (user / installer-written) event="${1:-}" @@ -75,30 +76,21 @@ esac [ -n "$event" ] || { printf '{}'; exit 0; } dbg "event=$event" -# ── credential resolution (later file wins; process env wins over all) ───── -_penv_ROGUE_API_KEY="${ROGUE_API_KEY:-}" -_penv_ROGUE_ACTOR_EMAIL="${ROGUE_ACTOR_EMAIL:-}" -_penv_ROGUE_ACTOR_NAME="${ROGUE_ACTOR_NAME:-}" -_penv_ROGUE_BASE_URL="${ROGUE_BASE_URL:-}" - +# ── credential resolution ────────────────────────────────────────────────── PLUGIN_ROOT="${CURSOR_PLUGIN_ROOT:-}" if [ -z "$PLUGIN_ROOT" ]; then PLUGIN_ROOT="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)" || PLUGIN_ROOT="" fi # Env files are bash-quoted (`export KEY=value`, written via printf %q), so -# sourcing them is correct. -for _f in "$PLUGIN_ROOT/env" /etc/rogue/env "$HOME/.rogue-env"; do - if [ -n "$_f" ] && [ -r "$_f" ]; then dbg "cred file found: $_f"; . "$_f" 2>/dev/null - else dbg "cred file absent: $_f"; fi +# sourcing them is correct. The first file holding ROGUE_API_KEY is used alone: +# machine, bundled, user. +for _f in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + if [ -r "$_f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_f"; then + dbg "cred file in use: $_f"; . "$_f" 2>/dev/null; break + else dbg "cred file skipped: $_f"; fi done -# process env wins over file values -[ -n "$_penv_ROGUE_API_KEY" ] && ROGUE_API_KEY="$_penv_ROGUE_API_KEY" -[ -n "$_penv_ROGUE_ACTOR_EMAIL" ] && ROGUE_ACTOR_EMAIL="$_penv_ROGUE_ACTOR_EMAIL" -[ -n "$_penv_ROGUE_ACTOR_NAME" ] && ROGUE_ACTOR_NAME="$_penv_ROGUE_ACTOR_NAME" -[ -n "$_penv_ROGUE_BASE_URL" ] && ROGUE_BASE_URL="$_penv_ROGUE_BASE_URL" - # ── hook log ─────────────────────────────────────────────────────────────── # `dbg` above only writes to stderr under ROGUE_DEBUG, which Cursor keeps in its # own per-session log — useless for after-the-fact diagnosis and unavailable to diff --git a/plugins/cursor/scripts/setup.ps1 b/plugins/cursor/scripts/setup.ps1 index ed0528b..5d13e0c 100644 --- a/plugins/cursor/scripts/setup.ps1 +++ b/plugins/cursor/scripts/setup.ps1 @@ -11,7 +11,7 @@ param( $ErrorActionPreference = 'Stop' -$EnvFile = if ($env:ROGUE_ENV_FILE) { $env:ROGUE_ENV_FILE } else { Join-Path $env:USERPROFILE '.rogue-env' } +$EnvFile = Join-Path $env:USERPROFILE '.rogue-env' . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'env-file.ps1')))) diff --git a/plugins/cursor/scripts/setup.sh b/plugins/cursor/scripts/setup.sh index 8b8fa04..2e652e2 100755 --- a/plugins/cursor/scripts/setup.sh +++ b/plugins/cursor/scripts/setup.sh @@ -9,7 +9,7 @@ API_KEY="${1:?Usage: setup.sh }" ACTOR_EMAIL="${2:-}" ACTOR_NAME="${3:-}" -ENV_FILE="${ROGUE_ENV_FILE:-$HOME/.rogue-env}" +ENV_FILE="$HOME/.rogue-env" . "$(dirname "$0")/env-file.sh" rogue_write_env_file "$ENV_FILE" \ diff --git a/plugins/cursor/scripts/ship-logs.ps1 b/plugins/cursor/scripts/ship-logs.ps1 index b375667..3b83519 100644 --- a/plugins/cursor/scripts/ship-logs.ps1 +++ b/plugins/cursor/scripts/ship-logs.ps1 @@ -320,8 +320,9 @@ function Initialize-Args { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same chain as every dispatcher (later file wins; process env wins over all): -# \env -> C:\ProgramData\rogue\env (MDM) -> %USERPROFILE%\.rogue-env +# Same rule as every dispatcher: the first trusted env file holding ROGUE_API_KEY +# is used alone, and its values override the process env: +# C:\ProgramData\rogue\env (machine, MDM) -> \env -> %USERPROFILE%\.rogue-env $SHIP_ENV_VARS = @( 'ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME', 'ROGUE_LOG_FILE', 'ROGUE_LOG_DIR', 'ROGUE_SHIP_MIN_INTERVAL', @@ -333,21 +334,25 @@ function Import-ShipEnv { if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) $resolved = @{} + foreach ($varName in $SHIP_ENV_VARS) { + $processValue = [Environment]::GetEnvironmentVariable($varName) + if ($processValue) { $resolved[$varName] = $processValue } + } $envFiles = @( - (Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', + (Join-Path $PluginRoot 'env'), (Join-Path (Get-UserHome) '.rogue-env')) foreach ($envFile in $envFiles) { if (-not $envFile -or -not (Test-Path -LiteralPath $envFile)) { continue } + $fileVals = @{} foreach ($line in (Read-RogueEnvFile $envFile)) { if ($line -match '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { - $resolved[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - foreach ($varName in $SHIP_ENV_VARS) { - $processValue = [Environment]::GetEnvironmentVariable($varName) - if ($processValue) { $resolved[$varName] = $processValue } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($varName in $fileVals.Keys) { $resolved[$varName] = $fileVals[$varName] } + break } $script:creds = $resolved } diff --git a/plugins/cursor/scripts/ship-logs.sh b/plugins/cursor/scripts/ship-logs.sh index ca5fbb1..ecdb7b3 100644 --- a/plugins/cursor/scripts/ship-logs.sh +++ b/plugins/cursor/scripts/ship-logs.sh @@ -290,26 +290,16 @@ parse_args() { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same platform-aware chain as every dispatcher (later file wins; process env -# wins over all files): -# /env -> /etc/rogue/env (MDM) -> $HOME/.rogue-env -# Process env is saved BEFORE sourcing, because `. file` overwrites it. -SHIP_ENV_VARS='ROGUE_API_KEY ROGUE_BASE_URL ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME -ROGUE_LOG_FILE ROGUE_LOG_DIR ROGUE_SHIP_MIN_INTERVAL -ROGUE_SHIP_MAX_BYTES ROGUE_SHIP_MAX_RUN_BYTES ROGUE_SHIP_MAX_LINE_BYTES -ROGUE_SHIP_ALL' - +# Same platform-aware rule as every dispatcher: the first trusted env file holding +# ROGUE_API_KEY is used alone, and its values override the process env: +# /etc/rogue/env (machine, MDM) -> /env -> $HOME/.rogue-env load_env() { [ -r "$(dirname "$0")/env-file.sh" ] || return 0 . "$(dirname "$0")/env-file.sh" - for _env_var_name in $SHIP_ENV_VARS; do - eval "_process_env_$_env_var_name=\${$_env_var_name:-}" - done - for _env_file in "$PLUGIN_ROOT/env" /etc/rogue/env "$HOME/.rogue-env"; do - rogue_source_env "$_env_file" 2>/dev/null - done - for _env_var_name in $SHIP_ENV_VARS; do - eval "[ -n \"\${_process_env_$_env_var_name:-}\" ] && $_env_var_name=\$_process_env_$_env_var_name" + for _env_file in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file" 2>/dev/null; then + . "$_env_file"; break + fi done return 0 } diff --git a/plugins/gemini/scripts/hook.mjs b/plugins/gemini/scripts/hook.mjs index 9c44641..41ebaf6 100644 --- a/plugins/gemini/scripts/hook.mjs +++ b/plugins/gemini/scripts/hook.mjs @@ -65,8 +65,8 @@ function emit(obj) { // is derived from them. `loadEnvFiles()` returns a MERGED OBJECT and deliberately // does not mutate `process.env`, so reading `process.env.ROGUE_LOG_DIR` directly // would silently ignore `~/.rogue-env` / `/etc/rogue/env` — the exact bug this -// replaced. Precedence inside the merge: bundled env → MDM → per-user, then -// process env wins (see shared.mjs). +// replaced. The merge is the process env with the first env file holding +// ROGUE_API_KEY laid over it (see shared.mjs). // Wrapped: a throw here would kill the hook before it could emit anything, and // Gemini must always get a body. An empty env degrades to "unconfigured". let ENV = {}; diff --git a/plugins/gemini/scripts/setup.mjs b/plugins/gemini/scripts/setup.mjs index eb28dda..51a52d3 100644 --- a/plugins/gemini/scripts/setup.mjs +++ b/plugins/gemini/scripts/setup.mjs @@ -7,8 +7,8 @@ // // Usage: node setup.mjs // -// Hooks read credentials from (later wins): /env → /etc/rogue/env -// (C:\ProgramData\rogue\env on Windows) → ~/.rogue-env (written here). +// Hooks read the first of these that holds ROGUE_API_KEY, alone: /etc/rogue/env +// (C:\ProgramData\rogue\env on Windows) → /env → ~/.rogue-env (written here). import fs from "node:fs"; import os from "node:os"; @@ -21,7 +21,7 @@ if (!apiKey) { } const HOME = os.homedir() || process.env.HOME || process.env.USERPROFILE || "."; -const ENV_FILE = process.env.ROGUE_ENV_FILE || path.join(HOME, ".rogue-env"); +const ENV_FILE = path.join(HOME, ".rogue-env"); const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; diff --git a/plugins/gemini/scripts/shared.mjs b/plugins/gemini/scripts/shared.mjs index 3781561..2dc24b2 100644 --- a/plugins/gemini/scripts/shared.mjs +++ b/plugins/gemini/scripts/shared.mjs @@ -36,14 +36,17 @@ export function shellUnquote(raw) { } // ── Credential resolution ──────────────────────────────────────────────────── -// Same env-file precedence as the other monorepo plugins (later wins; process -// env wins over all files): -// /env (bundled) → /etc/rogue/env (MDM) → ~/.rogue-env (per-user) +// Same env-file rule as the other monorepo plugins: the first file holding +// ROGUE_API_KEY is used alone, and its values override the process env: +// /etc/rogue/env (machine, MDM) → /env (bundled) → ~/.rogue-env (per-user) export function loadEnvFiles() { const merged = {}; + for (const k of Object.keys(process.env)) { + if (k.startsWith("ROGUE_") && process.env[k]) merged[k] = process.env[k]; + } const files = [ - path.join(EXT_ROOT, "env"), IS_WIN ? "C:\\ProgramData\\rogue\\env" : "/etc/rogue/env", + path.join(EXT_ROOT, "env"), path.join(HOME, ".rogue-env"), ]; for (const f of files) { @@ -53,14 +56,14 @@ export function loadEnvFiles() { } catch { continue; } + const vals = {}; for (const line of text.split(/\r?\n/)) { const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); - if (m) merged[m[1]] = shellUnquote(m[2]); + if (m) vals[m[1]] = shellUnquote(m[2]); } - } - // Process env wins (explicitly-set ROGUE_* / config knobs). - for (const k of Object.keys(process.env)) { - if (k.startsWith("ROGUE_") && process.env[k]) merged[k] = process.env[k]; + if (!vals.ROGUE_API_KEY) continue; + Object.assign(merged, vals); + break; } return merged; } diff --git a/plugins/gemini/scripts/ship-logs.mjs b/plugins/gemini/scripts/ship-logs.mjs index 0abd747..86bd0a7 100644 --- a/plugins/gemini/scripts/ship-logs.mjs +++ b/plugins/gemini/scripts/ship-logs.mjs @@ -50,15 +50,19 @@ const HTTP_TIMEOUT_MS = 15000; const HOME = os.homedir() || process.env.HOME || process.env.USERPROFILE || "."; // ── env files ────────────────────────────────────────────────────────────── -// Same platform-aware chain as every dispatcher (later file wins; process env wins -// over all files). Takes the root as an argument rather than using shared.mjs's -// EXT_ROOT-bound loadEnvFiles(), so the documented four-argument contract is real on -// this implementation too and a support run can point at any install. +// Same platform-aware rule as every dispatcher: the first env file holding +// ROGUE_API_KEY is used alone, and its values override the process env. Takes the +// root as an argument rather than using shared.mjs's EXT_ROOT-bound loadEnvFiles(), +// so the documented four-argument contract is real on this implementation too and a +// support run can point at any install. function loadEnv(pluginRoot) { const merged = {}; + for (const varName of Object.keys(process.env)) { + if (varName.startsWith("ROGUE_") && process.env[varName]) merged[varName] = process.env[varName]; + } const envFiles = [ - pluginRoot ? path.join(pluginRoot, "env") : null, IS_WIN ? "C:\\ProgramData\\rogue\\env" : "/etc/rogue/env", + pluginRoot ? path.join(pluginRoot, "env") : null, path.join(HOME, ".rogue-env"), ]; for (const envFile of envFiles) { @@ -69,13 +73,14 @@ function loadEnv(pluginRoot) { } catch { continue; } + const vals = {}; for (const line of text.split(/\r?\n/)) { const assignment = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); - if (assignment) merged[assignment[1]] = shellUnquote(assignment[2]); + if (assignment) vals[assignment[1]] = shellUnquote(assignment[2]); } - } - for (const varName of Object.keys(process.env)) { - if (varName.startsWith("ROGUE_") && process.env[varName]) merged[varName] = process.env[varName]; + if (!vals.ROGUE_API_KEY) continue; + Object.assign(merged, vals); + break; } return merged; } diff --git a/plugins/gemini/skills/status/SKILL.md b/plugins/gemini/skills/status/SKILL.md index f5e216f..be5208b 100644 --- a/plugins/gemini/skills/status/SKILL.md +++ b/plugins/gemini/skills/status/SKILL.md @@ -6,9 +6,9 @@ description: Check Rogue Security AIDR connection status, active rulesets, ident # Rogue Security Status Check the current status of the Rogue Security AIDR integration for Gemini CLI. -The hooks resolve credentials from three locations in order (later wins): the -extension's bundled `env` (managed installs), `/etc/rogue/env` (MDM), and -`~/.rogue-env` (per-user setup). This command checks all three. +The hooks read exactly one env file: the first of `/etc/rogue/env` (MDM), the +extension's bundled `env` (managed installs), and `~/.rogue-env` (per-user setup) +that holds `ROGUE_API_KEY`. This command applies the same rule. **Pick the command variant for the user's OS.** Use the macOS / Linux (bash) commands by default; use the Windows (PowerShell) block at the end on native @@ -19,12 +19,15 @@ Windows. There, the files are `C:\ProgramData\rogue\env` (MDM) and ```bash resolve() { - for f in "$HOME/.gemini/extensions/rogue/env" /etc/rogue/env "$HOME/.rogue-env"; do - [ -r "$f" ] && . "$f" && echo " $f" >&2 + # The first env file holding ROGUE_API_KEY is used alone. + for f in /etc/rogue/env "$HOME/.gemini/extensions/rogue/env" "$HOME/.rogue-env"; do + [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; echo " in use: $f" >&2; break; } done } resolve 2>/tmp/rogue-src -echo "Credential sources detected:"; cat /tmp/rogue-src 2>/dev/null || echo " (none)" +echo "Credential sources detected:" +for f in /etc/rogue/env "$HOME/.gemini/extensions/rogue/env" "$HOME/.rogue-env"; do [ -r "$f" ] && echo " $f"; done +cat /tmp/rogue-src 2>/dev/null || echo " (none holds ROGUE_API_KEY)" [ -n "${ROGUE_API_KEY:-}" ] && echo "API key resolved: ...${ROGUE_API_KEY: -4}" || echo "API key: not resolved" ``` @@ -39,7 +42,7 @@ version exists. Read the extension version from the manifest without `python3` (absent on a fresh macOS): ```bash -for f in "$HOME/.gemini/extensions/rogue/env" /etc/rogue/env "$HOME/.rogue-env"; do [ -r "$f" ] && . "$f"; done +for f in /etc/rogue/env "$HOME/.gemini/extensions/rogue/env" "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; }; done PJ="$HOME/.gemini/extensions/rogue/gemini-extension.json" VER=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[0-9][^"]*"' "$PJ" 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') curl -s -w "\n%{http_code}" -X POST \ @@ -56,7 +59,7 @@ dashboard). No response → check network reachability to `api.rogue.security`. ## Step 3: Fetch configuration ```bash -for f in "$HOME/.gemini/extensions/rogue/env" /etc/rogue/env "$HOME/.rogue-env"; do [ -r "$f" ] && . "$f"; done +for f in /etc/rogue/env "$HOME/.gemini/extensions/rogue/env" "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; }; done curl -s -H "x-rogue-api-key: $ROGUE_API_KEY" \ "${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/config" ``` @@ -70,20 +73,24 @@ Display: ## Step 4: Show identity + recent hook activity ```bash -for f in "$HOME/.gemini/extensions/rogue/env" /etc/rogue/env "$HOME/.rogue-env"; do [ -r "$f" ] && . "$f"; done +for f in /etc/rogue/env "$HOME/.gemini/extensions/rogue/env" "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; break; }; done echo "Actor email: ${ROGUE_ACTOR_EMAIL:-(unset)}" echo "Actor name: ${ROGUE_ACTOR_NAME:-(unset)}" echo "--- recent hook activity ---" -# Same precedence as the dispatcher: the env files first (system, then per-user), -# with the process environment winning over both. Read with sed, never by -# sourcing - a status command must not execute an env file. Reading only -# $ROGUE_LOG_* would report "no activity" on exactly the machines that relocate -# their logs by policy, which are the ones support is called about. +# Same rule as the dispatcher: only the env file in use (the first holding +# ROGUE_API_KEY) is read, with the process environment for anything it does not +# set. Read with sed, never by sourcing - a status command must not execute an env +# file. Reading only $ROGUE_LOG_* would report "no activity" on exactly the +# machines that relocate their logs by policy, which are the ones support is +# called about. +ROGUE_ENV_IN_USE="" +for f in /etc/rogue/env "$HOME/.gemini/extensions/rogue/env" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { ROGUE_ENV_IN_USE=$f; break; } +done rogue_log_var() { v=$(sed -n "s/^[[:space:]]*\(export[[:space:]][[:space:]]*\)\{0,1\}$1=//p" \ - /etc/rogue/env "$HOME/.rogue-env" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") - eval "p=\${$1:-}" - [ -n "$p" ] && v=$p + "${ROGUE_ENV_IN_USE:-/dev/null}" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") + [ -n "$v" ] || eval "v=\${$1:-}" printf '%s' "$v" } log=$(rogue_log_var ROGUE_LOG_FILE) @@ -180,14 +187,19 @@ user asks for an upload. ```powershell $creds = @{} -foreach ($f in @("$env:USERPROFILE\.gemini\extensions\rogue\env", 'C:\ProgramData\rogue\env', "$env:USERPROFILE\.rogue-env")) { - if (-not (Test-Path -LiteralPath $f)) { continue } - Write-Host " $f" +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +foreach ($f in @('C:\ProgramData\rogue\env', "$env:USERPROFILE\.gemini\extensions\rogue\env", "$env:USERPROFILE\.rogue-env")) { + if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $creds[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + Write-Host " in use: $f" + $creds = $fileVals + break } $key = $creds['ROGUE_API_KEY'] if (-not $key) { 'API key: not resolved — run /setup'; return } @@ -202,12 +214,11 @@ try { } catch { "Status check failed: $($_.Exception.Message)" } "Actor email: $($creds['ROGUE_ACTOR_EMAIL'])" "Actor name: $($creds['ROGUE_ACTOR_NAME'])" -# The process environment wins over every file, exactly as it does in the -# dispatcher - overlay it before deriving the path, or an operator who exported -# ROGUE_LOG_DIR for this session is told there is no activity. +# The process environment supplies only what the file in use does not set, exactly +# as in the dispatcher - so an operator who exported ROGUE_LOG_DIR for this session +# is still told where the log is. foreach ($v in 'ROGUE_LOG_FILE','ROGUE_LOG_DIR') { - $pv = [Environment]::GetEnvironmentVariable($v) - if ($pv) { $creds[$v] = $pv } + if (-not $creds[$v]) { $pv = [Environment]::GetEnvironmentVariable($v); if ($pv) { $creds[$v] = $pv } } } $logPath = $creds['ROGUE_LOG_FILE'] if (-not $logPath) { diff --git a/plugins/kiro/scripts/heartbeat.ps1 b/plugins/kiro/scripts/heartbeat.ps1 index 542b3e0..590c9da 100644 --- a/plugins/kiro/scripts/heartbeat.ps1 +++ b/plugins/kiro/scripts/heartbeat.ps1 @@ -111,19 +111,22 @@ function Get-BeaconLibrary { function Import-Credentials { $script:creds = @{} . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/env-file.ps1')))) - foreach ($f in @((Join-Path $pluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { + foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', + 'ROGUE_HEARTBEAT_MIN_INTERVAL') { + $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $script:creds[$k] = $val } + } + # The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $pluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Read-RogueEnvFile $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $script:creds[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - # ROGUE_HEARTBEAT_MIN_INTERVAL rides this list so a process-env value still beats - # the files. - foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', - 'ROGUE_HEARTBEAT_MIN_INTERVAL') { - $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $script:creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($k in $fileVals.Keys) { $script:creds[$k] = $fileVals[$k] } + break } $script:apiKey = $script:creds['ROGUE_API_KEY'] } diff --git a/plugins/kiro/scripts/heartbeat.sh b/plugins/kiro/scripts/heartbeat.sh index f0fabcb..410bf86 100755 --- a/plugins/kiro/scripts/heartbeat.sh +++ b/plugins/kiro/scripts/heartbeat.sh @@ -37,13 +37,15 @@ locate_plugin_root() { [ -n "$PLUGIN_ROOT" ] || PLUGIN_ROOT="${KIRO_PLUGIN_ROOT:-.}" } -# Same env precedence as hook.sh (later wins): bundled → MDM → per-user. load_env() { [ -r "${PLUGIN_ROOT}/scripts/env-file.sh" ] || return 0 . "${PLUGIN_ROOT}/scripts/env-file.sh" - rogue_source_env "${PLUGIN_ROOT}/env" - rogue_source_env /etc/rogue/env - rogue_source_env "$HOME/.rogue-env" + # The first trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi + done # Trim a trailing slash so a user-set ROGUE_BASE_URL with one doesn't yield # "//" in the composed URL (mirrors heartbeat.ps1's .TrimEnd('/')). ROGUE_BASE_URL="${ROGUE_BASE_URL:-}" diff --git a/plugins/kiro/scripts/hook.ps1 b/plugins/kiro/scripts/hook.ps1 index 86a022e..bee87d9 100644 --- a/plugins/kiro/scripts/hook.ps1 +++ b/plugins/kiro/scripts/hook.ps1 @@ -19,9 +19,10 @@ # timeout, non-200, empty body, an exception anywhere) this exits 0 with an # empty stdout. $ErrorActionPreference is SilentlyContinue for that reason. # -# Credential resolution (later file wins; process env wins over all): -# 1. \env (baked into a compiled customer plugin) -# 2. C:\ProgramData\rogue\env (MDM-provisioned; mirrors /etc/rogue/env) +# Credential resolution: the first env file holding ROGUE_API_KEY is used alone, +# and its values override the process env: +# 1. C:\ProgramData\rogue\env (machine, MDM-provisioned; mirrors /etc/rogue/env) +# 2. \env (bundled into a compiled customer plugin) # 3. %USERPROFILE%\.rogue-env (user / installer-written) # $SurfaceArg, not $Surface: PowerShell variable names are case-insensitive, so @@ -265,22 +266,25 @@ function Initialize-KiroContext { if (-not $PluginRoot) { $script:PluginRoot = $env:KIRO_PLUGIN_ROOT } if (-not $PluginRoot) { try { $script:PluginRoot = (Get-Location).Path } catch { $script:PluginRoot = '.' } } - # -- credential resolution (later file wins; process env wins over all) ----- + # -- credential resolution --------------------------------------------------- $script:creds = @{} . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/env-file.ps1')))) - foreach ($f in @((Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { + foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_API_URL', + 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES','ROGUE_HOOK_TIMEOUT') { + $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } + } + # The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $PluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Read-RogueEnvFile $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $creds[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - # ROGUE_LOG_* ride the same list so a process-env value still beats the files, - # which is what makes the resolved precedence identical to hook.sh's. - foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL','ROGUE_API_URL', - 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES','ROGUE_HOOK_TIMEOUT') { - $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break } # After the credential files (so they can relocate the log), before the API-key diff --git a/plugins/kiro/scripts/hook.sh b/plugins/kiro/scripts/hook.sh index 7657dee..a15225c 100755 --- a/plugins/kiro/scripts/hook.sh +++ b/plugins/kiro/scripts/hook.sh @@ -34,12 +34,10 @@ # decision blocks on the IDE only (model-mediated); timeout and exit 1 are both # fail-open. Hence the two transports. # -# Credential resolution (later file wins, INCLUDING over the process env — the -# files are sourced, and env-file.sh writes `export X=…`, so a value in a later -# file overwrites whatever the hook inherited; hook.ps1 differs and lets the -# process env beat every file): -# 1. ${PLUGIN_ROOT}/env (baked into a compiled customer plugin) -# 2. /etc/rogue/env (MDM-provisioned) +# Credential resolution: the first env file holding ROGUE_API_KEY is used alone, +# and its values override the process env: +# 1. /etc/rogue/env (machine, MDM-provisioned) +# 2. ${PLUGIN_ROOT}/env (bundled into a compiled customer plugin) # 3. $HOME/.rogue-env (per-user / installer-written) locate_plugin_root() { @@ -50,9 +48,12 @@ locate_plugin_root() { load_env() { [ -r "${PLUGIN_ROOT}/scripts/env-file.sh" ] || return 0 . "${PLUGIN_ROOT}/scripts/env-file.sh" - rogue_source_env "${PLUGIN_ROOT}/env" - rogue_source_env /etc/rogue/env - rogue_source_env "$HOME/.rogue-env" + # The first trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi + done } canonical_event() { diff --git a/plugins/kiro/scripts/ship-logs.ps1 b/plugins/kiro/scripts/ship-logs.ps1 index b375667..3b83519 100644 --- a/plugins/kiro/scripts/ship-logs.ps1 +++ b/plugins/kiro/scripts/ship-logs.ps1 @@ -320,8 +320,9 @@ function Initialize-Args { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same chain as every dispatcher (later file wins; process env wins over all): -# \env -> C:\ProgramData\rogue\env (MDM) -> %USERPROFILE%\.rogue-env +# Same rule as every dispatcher: the first trusted env file holding ROGUE_API_KEY +# is used alone, and its values override the process env: +# C:\ProgramData\rogue\env (machine, MDM) -> \env -> %USERPROFILE%\.rogue-env $SHIP_ENV_VARS = @( 'ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME', 'ROGUE_LOG_FILE', 'ROGUE_LOG_DIR', 'ROGUE_SHIP_MIN_INTERVAL', @@ -333,21 +334,25 @@ function Import-ShipEnv { if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) $resolved = @{} + foreach ($varName in $SHIP_ENV_VARS) { + $processValue = [Environment]::GetEnvironmentVariable($varName) + if ($processValue) { $resolved[$varName] = $processValue } + } $envFiles = @( - (Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', + (Join-Path $PluginRoot 'env'), (Join-Path (Get-UserHome) '.rogue-env')) foreach ($envFile in $envFiles) { if (-not $envFile -or -not (Test-Path -LiteralPath $envFile)) { continue } + $fileVals = @{} foreach ($line in (Read-RogueEnvFile $envFile)) { if ($line -match '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { - $resolved[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - foreach ($varName in $SHIP_ENV_VARS) { - $processValue = [Environment]::GetEnvironmentVariable($varName) - if ($processValue) { $resolved[$varName] = $processValue } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($varName in $fileVals.Keys) { $resolved[$varName] = $fileVals[$varName] } + break } $script:creds = $resolved } diff --git a/plugins/kiro/scripts/ship-logs.sh b/plugins/kiro/scripts/ship-logs.sh index ca5fbb1..ecdb7b3 100644 --- a/plugins/kiro/scripts/ship-logs.sh +++ b/plugins/kiro/scripts/ship-logs.sh @@ -290,26 +290,16 @@ parse_args() { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same platform-aware chain as every dispatcher (later file wins; process env -# wins over all files): -# /env -> /etc/rogue/env (MDM) -> $HOME/.rogue-env -# Process env is saved BEFORE sourcing, because `. file` overwrites it. -SHIP_ENV_VARS='ROGUE_API_KEY ROGUE_BASE_URL ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME -ROGUE_LOG_FILE ROGUE_LOG_DIR ROGUE_SHIP_MIN_INTERVAL -ROGUE_SHIP_MAX_BYTES ROGUE_SHIP_MAX_RUN_BYTES ROGUE_SHIP_MAX_LINE_BYTES -ROGUE_SHIP_ALL' - +# Same platform-aware rule as every dispatcher: the first trusted env file holding +# ROGUE_API_KEY is used alone, and its values override the process env: +# /etc/rogue/env (machine, MDM) -> /env -> $HOME/.rogue-env load_env() { [ -r "$(dirname "$0")/env-file.sh" ] || return 0 . "$(dirname "$0")/env-file.sh" - for _env_var_name in $SHIP_ENV_VARS; do - eval "_process_env_$_env_var_name=\${$_env_var_name:-}" - done - for _env_file in "$PLUGIN_ROOT/env" /etc/rogue/env "$HOME/.rogue-env"; do - rogue_source_env "$_env_file" 2>/dev/null - done - for _env_var_name in $SHIP_ENV_VARS; do - eval "[ -n \"\${_process_env_$_env_var_name:-}\" ] && $_env_var_name=\$_process_env_$_env_var_name" + for _env_file in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file" 2>/dev/null; then + . "$_env_file"; break + fi done return 0 } diff --git a/plugins/kiro/scripts/status.sh b/plugins/kiro/scripts/status.sh index 62b84fc..7877aad 100755 --- a/plugins/kiro/scripts/status.sh +++ b/plugins/kiro/scripts/status.sh @@ -35,13 +35,16 @@ surface_row() { printf ' %-12s%s\n' "$1" "$2"; } # The first "": "" value in a JSON body, without jq. json_str() { printf '%s' "$2" | sed -nE 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p' | head -n1; } -# ── credentials (same precedence as hook.sh: bundled → MDM → per-user) ────── +# ── credentials (same env file rule as hook.sh) ───────────────────────────── load_env() { [ -r "${PLUGIN_ROOT}/scripts/env-file.sh" ] || return 0 . "${PLUGIN_ROOT}/scripts/env-file.sh" - rogue_source_env "${PLUGIN_ROOT}/env" - rogue_source_env /etc/rogue/env - rogue_source_env "$HOME/.rogue-env" + # The first trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + for _env_file in /etc/rogue/env "${PLUGIN_ROOT}/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi + done ROGUE_BASE_URL="${ROGUE_BASE_URL:-https://api.rogue.security}" ROGUE_BASE_URL="${ROGUE_BASE_URL%/}" return 0 diff --git a/plugins/rogue/scripts/auto-update.ps1 b/plugins/rogue/scripts/auto-update.ps1 index a28e045..0170ca6 100644 --- a/plugins/rogue/scripts/auto-update.ps1 +++ b/plugins/rogue/scripts/auto-update.ps1 @@ -79,24 +79,26 @@ try { # quoting, but the only flags we read here are simple tokens). function ReadEnvVar { param([string]$Key) - $v = [Environment]::GetEnvironmentVariable($Key) - if ($v) { return $v } - # Same precedence as the dispatcher (later wins): bundled plugin env -> MDM -> - # per-user. The bundled ${CLAUDE_PLUGIN_ROOT}\env is where compiled/managed - # plugins pin flags like ROGUE_AUTO_UPDATE=0 / ROGUE_PLUGIN_VERSION. - $files = @() + # Same rule as the dispatcher: the first env file holding ROGUE_API_KEY is used + # alone (machine, bundled, user) and overrides the process env. The bundled + # ${CLAUDE_PLUGIN_ROOT}\env is where compiled/managed plugins pin flags like + # ROGUE_AUTO_UPDATE=0 / ROGUE_PLUGIN_VERSION. + $files = @('C:\ProgramData\rogue\env') if ($env:CLAUDE_PLUGIN_ROOT) { $files += (Join-Path $env:CLAUDE_PLUGIN_ROOT 'env') } - $files += 'C:\ProgramData\rogue\env' $files += (Join-Path $env:USERPROFILE '.rogue-env') foreach ($f in $files) { if (-not (Test-Path -LiteralPath $f)) { continue } + $vals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { - if ($line -match ('^\s*(?:export\s+)?' + [regex]::Escape($Key) + '=(.+)$')) { - $v = $Matches[1].Trim().Trim("'").Trim('"') + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { + $vals[$Matches[1]] = $Matches[2].Trim().Trim("'").Trim('"') } } + if (-not $vals['ROGUE_API_KEY']) { continue } + if ($vals.ContainsKey($Key)) { return $vals[$Key] } + break } - return $v + return [Environment]::GetEnvironmentVariable($Key) } if ((ReadEnvVar 'ROGUE_AUTO_UPDATE') -eq '0') { LogLine 'ROGUE_AUTO_UPDATE=0, skipping'; exit 0 } diff --git a/plugins/rogue/scripts/auto-update.sh b/plugins/rogue/scripts/auto-update.sh index b19a1ff..add7f84 100755 --- a/plugins/rogue/scripts/auto-update.sh +++ b/plugins/rogue/scripts/auto-update.sh @@ -27,13 +27,14 @@ mkdir -p "$(dirname "$LOG")" 2>/dev/null || exit 0 exec >>"$LOG" 2>&1 date "+%F %T --- auto-update tick ---" -# Pull creds + flags from the same files the hooks read, in the same precedence -# order (later wins): bundled plugin env → MDM → per-user. The bundled -# ${CLAUDE_PLUGIN_ROOT}/env is where compiled/managed plugins pin flags like -# ROGUE_AUTO_UPDATE=0 or ROGUE_PLUGIN_VERSION, so it must be sourced here too. -[ -r "${CLAUDE_PLUGIN_ROOT:-}/env" ] && . "${CLAUDE_PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# Same env file rule as the hooks. The bundled ${CLAUDE_PLUGIN_ROOT}/env is where +# compiled/managed plugins pin flags like ROGUE_AUTO_UPDATE=0 or ROGUE_PLUGIN_VERSION. +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +for _env_file in /etc/rogue/env "${CLAUDE_PLUGIN_ROOT:-}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi +done if [ "${ROGUE_AUTO_UPDATE:-1}" = "0" ]; then echo "ROGUE_AUTO_UPDATE=0, skipping" diff --git a/plugins/rogue/scripts/heartbeat.ps1 b/plugins/rogue/scripts/heartbeat.ps1 index bbf0d46..205f4d8 100644 --- a/plugins/rogue/scripts/heartbeat.ps1 +++ b/plugins/rogue/scripts/heartbeat.ps1 @@ -5,8 +5,8 @@ # Coding Agents roster and so the org learns which plugin version is running. Pure # side-effect: fire-and-forget, never blocks Claude Code, always exits 0. # -# Credential resolution mirrors hook.ps1 (later file wins; process env over all): -# 1. ${CLAUDE_PLUGIN_ROOT}\env 2. C:\ProgramData\rogue\env 3. %USERPROFILE%\.rogue-env +# Credential resolution mirrors hook.ps1: the first env file holding ROGUE_API_KEY +# is used alone (machine, bundled, user) and overrides the process env. # # TWO TRIGGERS, ONE SCRIPT, exactly as in heartbeat.sh. SessionStart fires once per # session; Stop fires once per TURN, so its beacon is throttled. Keep the two @@ -135,19 +135,22 @@ if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { exit 0 } # -- credential resolution -------------------------------------------------- $creds = @{} -foreach ($f in @((Join-Path $pluginRoot 'env'), 'C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', + 'ROGUE_HEARTBEAT_MIN_INTERVAL') { + $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } +} +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $pluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env'))) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $creds[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } -} -# ROGUE_HEARTBEAT_MIN_INTERVAL rides this list so a process-env value still beats -# the files, which is what makes the resolved precedence identical to hook.ps1's. -foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', - 'ROGUE_HEARTBEAT_MIN_INTERVAL') { - $val = [Environment]::GetEnvironmentVariable($k); if ($val) { $creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break } # Resolved HERE - after the env files are parsed so they can set it, and before the diff --git a/plugins/rogue/scripts/heartbeat.sh b/plugins/rogue/scripts/heartbeat.sh index 9f0c47c..acc4eb6 100755 --- a/plugins/rogue/scripts/heartbeat.sh +++ b/plugins/rogue/scripts/heartbeat.sh @@ -31,10 +31,12 @@ case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) exit 0 ;; esac -# Same env precedence as hook.sh (later wins): bundled → MDM → per-user. -[ -r "${CLAUDE_PLUGIN_ROOT:-}/env" ] && . "${CLAUDE_PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +for _env_file in /etc/rogue/env "${CLAUDE_PLUGIN_ROOT:-}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi +done # Not configured → no-op (mirrors hook.sh fail-open on missing key). [ -n "${ROGUE_API_KEY:-}" ] || exit 0 diff --git a/plugins/rogue/scripts/hook.ps1 b/plugins/rogue/scripts/hook.ps1 index b1613ec..5e39f9e 100644 --- a/plugins/rogue/scripts/hook.ps1 +++ b/plugins/rogue/scripts/hook.ps1 @@ -34,10 +34,10 @@ # yield `{}` on stdout, exit 0. Claude Code must never block because Rogue # infrastructure is unavailable. # -# Credential resolution (later file wins; process env wins over all), the Windows -# analogue of hook.sh's search: -# 1. ${CLAUDE_PLUGIN_ROOT}\env (baked into a compiled customer plugin) -# 2. C:\ProgramData\rogue\env (MDM-provisioned; mirrors /etc/rogue/env) +# Credential resolution, the Windows analogue of hook.sh's search: the first env +# file holding ROGUE_API_KEY is used alone, and its values override the process env: +# 1. C:\ProgramData\rogue\env (machine, MDM-provisioned; mirrors /etc/rogue/env) +# 2. ${CLAUDE_PLUGIN_ROOT}\env (bundled into a compiled customer plugin) # 3. %USERPROFILE%\.rogue-env (user / installer-written) param([string]$EventName = '') @@ -146,8 +146,8 @@ $script:logFile = $null $script:logMaxBytes = 10485760 function Initialize-Logging { - # $Creds is the merged credential map (bundled env → MDM → per-user file, then - # process env last), so precedence is already correct by the time we read it. + # $Creds is the resolved credential map (process env, then the chosen env file + # over it), so precedence is already correct by the time we read it. # $HOME backs up USERPROFILE so this also works dot-sourced on macOS/Linux. param([hashtable]$Creds = @{}) $f = $Creds['ROGUE_LOG_FILE'] @@ -329,31 +329,34 @@ try { } catch { $script:surface = '' } Dbg "surface=$($script:surface)" -# -- credential resolution (later file wins; process env wins over all) ----- +# -- credential resolution --------------------------------------------------- $creds = @{} +foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', + 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES') { + $val = [Environment]::GetEnvironmentVariable($k) + if ($val) { $creds[$k] = $val } +} +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. $credFiles = @( - (Join-Path $pluginRoot 'env'), 'C:\ProgramData\rogue\env', + (Join-Path $pluginRoot 'env'), (Join-Path $env:USERPROFILE '.rogue-env') ) foreach ($f in $credFiles) { if (-not $f) { continue } if (-not (Test-Path -LiteralPath $f)) { Dbg "cred file absent: $f"; continue } - Dbg "cred file found: $f" + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $k = $Matches[1] - $v = ConvertFrom-ShellQuoted ($Matches[2].Trim()) - $creds[$k] = $v + # Decode shell quoting/escaping so the value round-trips with the + # `source`-based parse in hook.sh (mirrors shlex.split). + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } -} -# ROGUE_LOG_* ride the same list so a process-env value still beats the files, -# which is what makes the resolved precedence identical to hook.sh's. -foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BASE_URL', - 'ROGUE_LOG_FILE','ROGUE_LOG_DIR','ROGUE_LOG_MAX_BYTES') { - $val = [Environment]::GetEnvironmentVariable($k) - if ($val) { $creds[$k] = $val } + if (-not $fileVals['ROGUE_API_KEY']) { Dbg "cred file skipped: $f"; continue } + Dbg "cred file in use: $f" + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break } # Logging is initialised HERE - after the credential files are parsed, so they can diff --git a/plugins/rogue/scripts/hook.sh b/plugins/rogue/scripts/hook.sh index 3d67130..6b3fa7e 100644 --- a/plugins/rogue/scripts/hook.sh +++ b/plugins/rogue/scripts/hook.sh @@ -12,9 +12,12 @@ case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) echo '{}'; exit 0 ;; esac -[ -r "${CLAUDE_PLUGIN_ROOT}/env" ] && . "${CLAUDE_PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +for _env_file in /etc/rogue/env "${CLAUDE_PLUGIN_ROOT:-}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi +done [ -z "${CLAUDE_CODE_ENTRYPOINT:-}" ] && echo '{}' && exit 0 diff --git a/plugins/rogue/scripts/setup.ps1 b/plugins/rogue/scripts/setup.ps1 index 3eb48c4..e6e3a60 100644 --- a/plugins/rogue/scripts/setup.ps1 +++ b/plugins/rogue/scripts/setup.ps1 @@ -11,7 +11,7 @@ param( $ErrorActionPreference = 'Stop' -$EnvFile = if ($env:ROGUE_ENV_FILE) { $env:ROGUE_ENV_FILE } else { Join-Path $env:USERPROFILE '.rogue-env' } +$EnvFile = Join-Path $env:USERPROFILE '.rogue-env' . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'env-file.ps1')))) diff --git a/plugins/rogue/scripts/setup.sh b/plugins/rogue/scripts/setup.sh index c7339d7..2f27738 100755 --- a/plugins/rogue/scripts/setup.sh +++ b/plugins/rogue/scripts/setup.sh @@ -8,14 +8,15 @@ set -euo pipefail # Usage: setup.sh # # Hooks read credentials from (in order): -# 1) /etc/rogue/env (system-wide, for MDM deployments) -# 2) ~/.rogue-env (per-user, written by this script) +# 1) /etc/rogue/env (machine, for MDM deployments) +# 2) ${CLAUDE_PLUGIN_ROOT}/env (bundled, for compiled customer plugins) +# 3) ~/.rogue-env (per-user, written by this script) API_KEY="${1:?Usage: setup.sh }" ACTOR_EMAIL="${2:-}" ACTOR_NAME="${3:-}" -ENV_FILE="${ROGUE_ENV_FILE:-$HOME/.rogue-env}" +ENV_FILE="$HOME/.rogue-env" . "$(dirname "$0")/env-file.sh" rogue_write_env_file "$ENV_FILE" \ diff --git a/plugins/rogue/scripts/ship-logs.ps1 b/plugins/rogue/scripts/ship-logs.ps1 index b375667..3b83519 100644 --- a/plugins/rogue/scripts/ship-logs.ps1 +++ b/plugins/rogue/scripts/ship-logs.ps1 @@ -320,8 +320,9 @@ function Initialize-Args { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same chain as every dispatcher (later file wins; process env wins over all): -# \env -> C:\ProgramData\rogue\env (MDM) -> %USERPROFILE%\.rogue-env +# Same rule as every dispatcher: the first trusted env file holding ROGUE_API_KEY +# is used alone, and its values override the process env: +# C:\ProgramData\rogue\env (machine, MDM) -> \env -> %USERPROFILE%\.rogue-env $SHIP_ENV_VARS = @( 'ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME', 'ROGUE_LOG_FILE', 'ROGUE_LOG_DIR', 'ROGUE_SHIP_MIN_INTERVAL', @@ -333,21 +334,25 @@ function Import-ShipEnv { if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) $resolved = @{} + foreach ($varName in $SHIP_ENV_VARS) { + $processValue = [Environment]::GetEnvironmentVariable($varName) + if ($processValue) { $resolved[$varName] = $processValue } + } $envFiles = @( - (Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', + (Join-Path $PluginRoot 'env'), (Join-Path (Get-UserHome) '.rogue-env')) foreach ($envFile in $envFiles) { if (-not $envFile -or -not (Test-Path -LiteralPath $envFile)) { continue } + $fileVals = @{} foreach ($line in (Read-RogueEnvFile $envFile)) { if ($line -match '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { - $resolved[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - foreach ($varName in $SHIP_ENV_VARS) { - $processValue = [Environment]::GetEnvironmentVariable($varName) - if ($processValue) { $resolved[$varName] = $processValue } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($varName in $fileVals.Keys) { $resolved[$varName] = $fileVals[$varName] } + break } $script:creds = $resolved } diff --git a/plugins/rogue/scripts/ship-logs.sh b/plugins/rogue/scripts/ship-logs.sh index ca5fbb1..ecdb7b3 100644 --- a/plugins/rogue/scripts/ship-logs.sh +++ b/plugins/rogue/scripts/ship-logs.sh @@ -290,26 +290,16 @@ parse_args() { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same platform-aware chain as every dispatcher (later file wins; process env -# wins over all files): -# /env -> /etc/rogue/env (MDM) -> $HOME/.rogue-env -# Process env is saved BEFORE sourcing, because `. file` overwrites it. -SHIP_ENV_VARS='ROGUE_API_KEY ROGUE_BASE_URL ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME -ROGUE_LOG_FILE ROGUE_LOG_DIR ROGUE_SHIP_MIN_INTERVAL -ROGUE_SHIP_MAX_BYTES ROGUE_SHIP_MAX_RUN_BYTES ROGUE_SHIP_MAX_LINE_BYTES -ROGUE_SHIP_ALL' - +# Same platform-aware rule as every dispatcher: the first trusted env file holding +# ROGUE_API_KEY is used alone, and its values override the process env: +# /etc/rogue/env (machine, MDM) -> /env -> $HOME/.rogue-env load_env() { [ -r "$(dirname "$0")/env-file.sh" ] || return 0 . "$(dirname "$0")/env-file.sh" - for _env_var_name in $SHIP_ENV_VARS; do - eval "_process_env_$_env_var_name=\${$_env_var_name:-}" - done - for _env_file in "$PLUGIN_ROOT/env" /etc/rogue/env "$HOME/.rogue-env"; do - rogue_source_env "$_env_file" 2>/dev/null - done - for _env_var_name in $SHIP_ENV_VARS; do - eval "[ -n \"\${_process_env_$_env_var_name:-}\" ] && $_env_var_name=\$_process_env_$_env_var_name" + for _env_file in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file" 2>/dev/null; then + . "$_env_file"; break + fi done return 0 } diff --git a/plugins/rogue/scripts/statusline.sh b/plugins/rogue/scripts/statusline.sh index 2d47652..1aca324 100755 --- a/plugins/rogue/scripts/statusline.sh +++ b/plugins/rogue/scripts/statusline.sh @@ -10,7 +10,7 @@ set -u for f in /etc/rogue/env "$HOME/.rogue-env"; do - [ -r "$f" ] && . "$f" + if [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f"; then . "$f"; break; fi done if [ -n "${ROGUE_API_KEY:-}" ]; then diff --git a/plugins/rogue/scripts/warn.sh b/plugins/rogue/scripts/warn.sh index bc5a5cf..fedfdad 100644 --- a/plugins/rogue/scripts/warn.sh +++ b/plugins/rogue/scripts/warn.sh @@ -7,9 +7,12 @@ case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) exit 0 ;; esac -[ -r "${CLAUDE_PLUGIN_ROOT}/env" ] && . "${CLAUDE_PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +for _env_file in /etc/rogue/env "${CLAUDE_PLUGIN_ROOT:-}/env" "$HOME/.rogue-env"; do + if [ -r "$_env_file" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file"; then + . "$_env_file"; break + fi +done [ -z "${CLAUDE_CODE_ENTRYPOINT:-}" ] && exit 0 diff --git a/plugins/rogue/skills/status/SKILL.md b/plugins/rogue/skills/status/SKILL.md index 0b75b12..d73a08d 100644 --- a/plugins/rogue/skills/status/SKILL.md +++ b/plugins/rogue/skills/status/SKILL.md @@ -5,10 +5,10 @@ description: Check Rogue Security AIDR connection status, active rulesets, and c # Rogue Security Status Check the current status of the Rogue Security AIDR integration. The plugin hooks -source credentials from three locations in order (later wins): the plugin's bundled -`env` (managed installs), `/etc/rogue/env` (MDM-provisioned), and `~/.rogue-env` -(per-user setup). This command checks all three so it works for managed, MDM, and -individual deployments. +read exactly one env file: the first of `/etc/rogue/env` (MDM-provisioned), the +plugin's bundled `env` (managed installs), and `~/.rogue-env` (per-user setup) that +holds `ROGUE_API_KEY`. This command applies the same rule and reports which file is +in use. **Pick the command variant for the user's OS.** The steps below use **macOS / Linux (bash)** commands. On **native Windows (no WSL)**, use the PowerShell equivalents in the "Windows (PowerShell)" block at the end of this command instead — the credential files there are `C:\ProgramData\rogue\env` (MDM) and `%USERPROFILE%\.rogue-env` (per-user), and the plugin bundle `env` lives under `$env:USERPROFILE\.claude\plugins`. @@ -20,13 +20,15 @@ helper written to `/tmp/`: ```bash cat > /tmp/rogue-source-env.sh <<'EOF' PLUGIN_ENV=$(find "$HOME/.claude/plugins" -name env -type f -path '*rogue*' 2>/dev/null | head -1) -[ -n "$PLUGIN_ENV" ] && [ -r "$PLUGIN_ENV" ] && . "$PLUGIN_ENV" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +ROGUE_ENV_IN_USE="" +# The first env file holding ROGUE_API_KEY is used alone. +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { . "$f"; ROGUE_ENV_IN_USE=$f; break; } +done EOF chmod +x /tmp/rogue-source-env.sh -# Report which sources contributed +# Report which sources exist and which one is in use . /tmp/rogue-source-env.sh echo "Credential sources detected:" PLUGIN_ENV=$(find "$HOME/.claude/plugins" -name env -type f -path '*rogue*' 2>/dev/null | head -1) @@ -34,6 +36,7 @@ PLUGIN_ENV=$(find "$HOME/.claude/plugins" -name env -type f -path '*rogue*' 2>/d [ -r /etc/rogue/env ] && echo " /etc/rogue/env (MDM)" [ -r "$HOME/.rogue-env" ] && echo " $HOME/.rogue-env (per-user)" [ -z "$PLUGIN_ENV" ] && [ ! -r /etc/rogue/env ] && [ ! -r "$HOME/.rogue-env" ] && echo " (none)" +echo "In use: ${ROGUE_ENV_IN_USE:-(none holds ROGUE_API_KEY)}" # Sanity check the resolved key [ -n "$ROGUE_API_KEY" ] && echo "API key resolved: ...${ROGUE_API_KEY: -4}" || echo "API key: not resolved" @@ -114,8 +117,8 @@ Report from the JSON response (HTTP 200 = connected): On failure suggest: - HTTP 401 → key invalid. Compare the resolved key tail (Step 1) against the - [API keys dashboard](https://app.rogue.security/settings/api-keys); the - precedence chain may be picking up a stale source — check Step 1's list. + [API keys dashboard](https://app.rogue.security/settings/api-keys); the file + in use may be stale — check Step 1's `In use:` line. - HTTP 400 → the JSON body was malformed or `agent_family` was missing; print the body the command sent and compare it with `scripts/heartbeat.sh`. - HTTP 404 → the URL is wrong (a stale `ROGUE_BASE_URL`, or a path other than @@ -163,16 +166,20 @@ echo "Actor name: ${ROGUE_ACTOR_NAME:-(unresolved)}" [ "$RAW_NAME" = "${ROGUE_ACTOR_NAME:-}" ] || \ echo " note: env file holds \"${RAW_NAME:-(unset)}\", replaced by the cascade" echo "--- recent hook activity ---" -# Same precedence as the dispatcher: the env files first (system, then per-user), -# with the process environment winning over both. Read with sed, never by -# sourcing - a status command must not execute an env file. Reading only -# $ROGUE_LOG_* would report "no activity" on exactly the machines that relocate -# their logs by policy, which are the ones support is called about. +# Same rule as the dispatcher: only the env file in use (the first holding +# ROGUE_API_KEY) is read, with the process environment for anything it does not +# set. Read with sed, never by sourcing - a status command must not execute an env +# file. Reading only $ROGUE_LOG_* would report "no activity" on exactly the +# machines that relocate their logs by policy, which are the ones support is +# called about. +ROGUE_ENV_IN_USE="" +for f in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$f" && { ROGUE_ENV_IN_USE=$f; break; } +done rogue_log_var() { v=$(sed -n "s/^[[:space:]]*\(export[[:space:]][[:space:]]*\)\{0,1\}$1=//p" \ - /etc/rogue/env "$HOME/.rogue-env" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") - eval "p=\${$1:-}" - [ -n "$p" ] && v=$p + "${ROGUE_ENV_IN_USE:-/dev/null}" 2>/dev/null | tail -1 | sed "s/^['\"]//;s/['\"]$//") + [ -n "$v" ] || eval "v=\${$1:-}" printf '%s' "$v" } log=$(rogue_log_var ROGUE_LOG_FILE) @@ -288,7 +295,7 @@ else { $env:ROGUE_SHIP_MIN_INTERVAL = '0'; $env:ROGUE_DEBUG = '1' $env:ROGUE_SHIPPER_SCRIPT = $ship # PASS THE ROOT. On a no-argument run the shipper self-locates its plugin root to - # read \env, the FIRST file in the credential chain - and $PSCommandPath is + # read \env, a candidate in the credential chain - and $PSCommandPath is # EMPTY under [scriptblock]::Create, so it falls back to the current directory, # which is the operator's cwd and has no env file. The bundled ROGUE_BASE_URL is # then missed and identity can be absent entirely (outcome=skip reason=no-actor), @@ -318,13 +325,10 @@ installed), then the newest **non-orphaned** copy under **Which copy runs matters, so report the path it prints.** On a no-argument run the shipper self-locates its plugin root from its own script path and reads -`/env` as the *first* file in the credential chain, so a stale tree -supplies credentials — and while a later `~/.rogue-env` overrides the API key, -`setup.sh` writes no `ROGUE_BASE_URL` of its own, so a stale base URL in an -orphaned tree's bundled `env` would win and the upload would go to the wrong -host. (One added to `~/.rogue-env` by hand does now survive: every writer -merges rather than truncating, so setup and auto-update keep it.) Hence all -three layers prefer the installed tree and skip anything carrying Claude Code's +`/env` as a credential candidate (after `/etc/rogue/env`), so a stale +tree whose bundled `env` holds a key supplies the credentials alone — `~/.rogue-env` +is not read at all then, and a stale base URL in that tree would send the upload to +the wrong host. Hence all three layers prefer the installed tree and skip anything carrying Claude Code's `.orphaned_at` marker, and the command echoes the path it chose. This is also why "any copy will do" is wrong even though `ship-logs.sh` is byte-identical across the five sh plugins (`scripts/sync-shared-scripts.sh --check` enforces that): the @@ -379,21 +383,26 @@ After the summary, tell the user: ## Windows (PowerShell) On native Windows (no WSL), run this single block instead of Steps 1–4. It -resolves credentials (later source wins), reports what was found, registers the -heartbeat, and prints the resolved identity: +resolves credentials (the first file holding `ROGUE_API_KEY`), reports the file in +use, registers the heartbeat, and prints the resolved identity: ```powershell -$creds = @{} $pluginEnv = Get-ChildItem "$env:USERPROFILE\.claude\plugins" -Recurse -Filter env -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -like '*rogue*' } | Select-Object -First 1 -foreach ($f in @($pluginEnv.FullName, 'C:\ProgramData\rogue\env', "$env:USERPROFILE\.rogue-env")) { +$creds = @{} +# The first env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +foreach ($f in @('C:\ProgramData\rogue\env', $pluginEnv.FullName, "$env:USERPROFILE\.rogue-env")) { if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } - Write-Host " $f" + $fileVals = @{} foreach ($line in (Get-Content -LiteralPath $f)) { if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $creds[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + Write-Host " in use: $f" + $creds = $fileVals + break } $key = $creds['ROGUE_API_KEY'] if (-not $key) { 'API key: not resolved — run /rogue:setup'; return } @@ -459,12 +468,11 @@ try { "Actor email: $actorEmail" "Actor name: $actorName" '--- recent hook activity ---' -# The process environment wins over every file, exactly as it does in the -# dispatcher - overlay it before deriving the path, or an operator who exported -# ROGUE_LOG_DIR for this session is told there is no activity. +# The process environment supplies only what the file in use does not set, exactly +# as in the dispatcher - so an operator who exported ROGUE_LOG_DIR for this session +# is still told where the log is. foreach ($v in 'ROGUE_LOG_FILE','ROGUE_LOG_DIR') { - $pv = [Environment]::GetEnvironmentVariable($v) - if ($pv) { $creds[$v] = $pv } + if (-not $creds[$v]) { $pv = [Environment]::GetEnvironmentVariable($v); if ($pv) { $creds[$v] = $pv } } } $logPath = $creds['ROGUE_LOG_FILE'] if (-not $logPath) { diff --git a/scripts/compile-customer-plugin.sh b/scripts/compile-customer-plugin.sh index eb095a1..b657cc1 100755 --- a/scripts/compile-customer-plugin.sh +++ b/scripts/compile-customer-plugin.sh @@ -3,8 +3,8 @@ # # The resulting zip can be dragged into Claude Code without the customer # running /rogue:setup — the API key is baked into an `env` file at the -# plugin root, sourced by every hook before the standard locations -# (/etc/rogue/env and ~/.rogue-env, which still override if present). +# plugin root, which every hook sources when no /etc/rogue/env holds a key +# (~/.rogue-env is then not read at all). # # Actor identity (email/name) is intentionally NOT compiled in. It is # derived per-user at hook-fire time from git config / $USER on the diff --git a/scripts/compile-local-dev.sh b/scripts/compile-local-dev.sh index 98410c0..1a94875 100755 --- a/scripts/compile-local-dev.sh +++ b/scripts/compile-local-dev.sh @@ -99,8 +99,8 @@ if ! git -C "$REPO_ROOT" diff --quiet 2>/dev/null || ! git -C "$REPO_ROOT" diff fi # Optionally bake the API key + config into ${CLAUDE_PLUGIN_ROOT}/env. Hooks -# source this before /etc/rogue/env and ~/.rogue-env, so per-user overrides -# still win. +# source this when no /etc/rogue/env holds a key, and then read ~/.rogue-env +# not at all. # # Deliberately NO actor pre-seed here. This file used to emit # : "${ROGUE_ACTOR_EMAIL:=$(git config --global user.email)}" diff --git a/scripts/shared/ship-logs.ps1 b/scripts/shared/ship-logs.ps1 index b375667..3b83519 100644 --- a/scripts/shared/ship-logs.ps1 +++ b/scripts/shared/ship-logs.ps1 @@ -320,8 +320,9 @@ function Initialize-Args { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same chain as every dispatcher (later file wins; process env wins over all): -# \env -> C:\ProgramData\rogue\env (MDM) -> %USERPROFILE%\.rogue-env +# Same rule as every dispatcher: the first trusted env file holding ROGUE_API_KEY +# is used alone, and its values override the process env: +# C:\ProgramData\rogue\env (machine, MDM) -> \env -> %USERPROFILE%\.rogue-env $SHIP_ENV_VARS = @( 'ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME', 'ROGUE_LOG_FILE', 'ROGUE_LOG_DIR', 'ROGUE_SHIP_MIN_INTERVAL', @@ -333,21 +334,25 @@ function Import-ShipEnv { if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) $resolved = @{} + foreach ($varName in $SHIP_ENV_VARS) { + $processValue = [Environment]::GetEnvironmentVariable($varName) + if ($processValue) { $resolved[$varName] = $processValue } + } $envFiles = @( - (Join-Path $PluginRoot 'env'), 'C:\ProgramData\rogue\env', + (Join-Path $PluginRoot 'env'), (Join-Path (Get-UserHome) '.rogue-env')) foreach ($envFile in $envFiles) { if (-not $envFile -or -not (Test-Path -LiteralPath $envFile)) { continue } + $fileVals = @{} foreach ($line in (Read-RogueEnvFile $envFile)) { if ($line -match '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { - $resolved[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) } } - } - foreach ($varName in $SHIP_ENV_VARS) { - $processValue = [Environment]::GetEnvironmentVariable($varName) - if ($processValue) { $resolved[$varName] = $processValue } + if (-not $fileVals['ROGUE_API_KEY']) { continue } + foreach ($varName in $fileVals.Keys) { $resolved[$varName] = $fileVals[$varName] } + break } $script:creds = $resolved } diff --git a/scripts/shared/ship-logs.sh b/scripts/shared/ship-logs.sh index ca5fbb1..ecdb7b3 100644 --- a/scripts/shared/ship-logs.sh +++ b/scripts/shared/ship-logs.sh @@ -290,26 +290,16 @@ parse_args() { } # ── stage 3: env files + knobs ───────────────────────────────────────────── -# Same platform-aware chain as every dispatcher (later file wins; process env -# wins over all files): -# /env -> /etc/rogue/env (MDM) -> $HOME/.rogue-env -# Process env is saved BEFORE sourcing, because `. file` overwrites it. -SHIP_ENV_VARS='ROGUE_API_KEY ROGUE_BASE_URL ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME -ROGUE_LOG_FILE ROGUE_LOG_DIR ROGUE_SHIP_MIN_INTERVAL -ROGUE_SHIP_MAX_BYTES ROGUE_SHIP_MAX_RUN_BYTES ROGUE_SHIP_MAX_LINE_BYTES -ROGUE_SHIP_ALL' - +# Same platform-aware rule as every dispatcher: the first trusted env file holding +# ROGUE_API_KEY is used alone, and its values override the process env: +# /etc/rogue/env (machine, MDM) -> /env -> $HOME/.rogue-env load_env() { [ -r "$(dirname "$0")/env-file.sh" ] || return 0 . "$(dirname "$0")/env-file.sh" - for _env_var_name in $SHIP_ENV_VARS; do - eval "_process_env_$_env_var_name=\${$_env_var_name:-}" - done - for _env_file in "$PLUGIN_ROOT/env" /etc/rogue/env "$HOME/.rogue-env"; do - rogue_source_env "$_env_file" 2>/dev/null - done - for _env_var_name in $SHIP_ENV_VARS; do - eval "[ -n \"\${_process_env_$_env_var_name:-}\" ] && $_env_var_name=\$_process_env_$_env_var_name" + for _env_file in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq '^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=' "$_env_file" 2>/dev/null; then + . "$_env_file"; break + fi done return 0 } From d6ab85db5aad11d8915c3a8cd1e2c1ee9d892981 Mon Sep 17 00:00:00 2001 From: Yuval Date: Thu, 10 Sep 2026 22:40:13 +0300 Subject: [PATCH 02/11] test(plugins): cover the first-found env file rule (FIRE-2116) New suites for sh, PowerShell and node exercise every reader with the machine path redirected into a sandbox: three files present uses the machine file alone, a machine file without ROGUE_API_KEY falls through to the next candidate, and the chosen file overrides the process env. Existing suites that encoded the old later-wins rule, or redirected the writers through ROGUE_ENV_FILE, now stage a HOME/USERPROFILE and an env file that holds the key. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/validate.yml | 9 +- tests/e2e_receiver.mjs | 10 +- tests/e2e_ship_logs.ps1 | 4 +- tests/e2e_ship_logs.sh | 4 +- tests/log_probe.ps1 | 4 +- tests/test_env_first_found.mjs | 200 +++++++++++++++++++++++++++++++++ tests/test_env_first_found.ps1 | 154 +++++++++++++++++++++++++ tests/test_env_first_found.sh | 153 +++++++++++++++++++++++++ tests/test_heartbeat_ps1.ps1 | 10 +- tests/test_hook_logs.ps1 | 4 +- tests/test_hook_logs.sh | 8 +- tests/test_hook_sh_kiro.sh | 31 ++--- tests/test_setup_env.ps1 | 18 +-- tests/test_setup_env.sh | 38 +++---- tests/test_ship_logs.sh | 19 +++- 15 files changed, 599 insertions(+), 67 deletions(-) create mode 100644 tests/test_env_first_found.mjs create mode 100644 tests/test_env_first_found.ps1 create mode 100644 tests/test_env_first_found.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b33042d..f9cdfb8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -116,6 +116,8 @@ jobs: TEST_SH=bash bash tests/test_setup_env.sh SH=bash bash tests/test_hook_sh_cursor.sh TEST_SH=dash bash tests/test_hook_sh_cursor.sh + TEST_SH=dash bash tests/test_env_first_found.sh + TEST_SH=bash bash tests/test_env_first_found.sh - name: Kiro installer (temp HOME, fake kiro-cli) # install.sh --kiro is the only installer that WRITES the vendor's hook # wiring itself (a hook file, Crew wrappers, a merge into every agent @@ -228,7 +230,9 @@ jobs: # and nothing invoked it. It carries the subagent-attribution rules, whose # failure mode is a WRONG x-rogue-agent-id on a main-agent tool row: a # false attribution in an audit trail, which no other gate can see. - run: node --test tests/test_hook_mjs.mjs + run: | + node --test tests/test_hook_mjs.mjs + node --test tests/test_env_first_found.mjs - name: Log-shipper contract (sh) # The shipper is a byte-offset state machine over a file another process is @@ -355,6 +359,7 @@ jobs: pwsh -NoProfile -File tests/test_auto_update_ps1.ps1 pwsh -NoProfile -File tests/test_setup_env.ps1 pwsh -NoProfile -File tests/test_env_file_trust.ps1 + pwsh -NoProfile -File tests/test_env_first_found.ps1 pwsh -NoProfile -File tests/test_install_kiro_ps1.ps1 pwsh -NoProfile -File tests/test_status_kiro_ps1.ps1 @@ -415,6 +420,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit 1 } powershell -NoProfile -File tests/test_env_file_trust.ps1 if ($LASTEXITCODE -ne 0) { exit 1 } + powershell -NoProfile -File tests/test_env_first_found.ps1 + if ($LASTEXITCODE -ne 0) { exit 1 } powershell -NoProfile -File tests/test_install_kiro_ps1.ps1 if ($LASTEXITCODE -ne 0) { exit 1 } powershell -NoProfile -File tests/test_status_kiro_ps1.ps1 diff --git a/tests/e2e_receiver.mjs b/tests/e2e_receiver.mjs index 4f069b3..3e1c90c 100644 --- a/tests/e2e_receiver.mjs +++ b/tests/e2e_receiver.mjs @@ -37,9 +37,9 @@ fs.mkdirSync(workDir, { recursive: true }); const EXPECTED_KEY = process.env.E2E_API_KEY || "e2e-key"; // E2E_ACCEPT_ANY_KEY=1 accepts whatever key arrives. Needed by // tests/manual/live_session.sh, where the request comes from a REAL Claude Code -// session: the sh dispatchers source ~/.rogue-env after reading the process -// environment, so that file's ROGUE_API_KEY wins over the sandbox's and the run would -// 401 on the developer's own credential. +// session: the dispatchers' env file in use overrides the process environment, so +// that file's ROGUE_API_KEY wins over the sandbox's and the run would 401 on the +// developer's own credential. const ACCEPT_ANY_KEY = process.env.E2E_ACCEPT_ANY_KEY === "1"; function readStatusCode() { @@ -69,8 +69,8 @@ const server = http.createServer((req, res) => { if (!ACCEPT_ANY_KEY && req.headers["x-rogue-api-key"] !== EXPECTED_KEY) { // A FINGERPRINT, never the key. This used to append the rejected value // verbatim, and the live-session run put a developer's real ROGUE_API_KEY - // into a world-readable file under /tmp: the sh dispatchers let ~/.rogue-env - // override the process environment, so the key that arrives here is not + // into a world-readable file under /tmp: the dispatchers' env file overrides + // the process environment, so the key that arrives here is not // necessarily the sandbox's. Eight hex characters is enough to tell two // wrong keys apart, which is all this file is for. const received = String(req.headers["x-rogue-api-key"] ?? ""); diff --git a/tests/e2e_ship_logs.ps1 b/tests/e2e_ship_logs.ps1 index fe8d476..31e2a3c 100644 --- a/tests/e2e_ship_logs.ps1 +++ b/tests/e2e_ship_logs.ps1 @@ -45,8 +45,8 @@ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { # Scrub every knob the shipper reads from the environment. NOT optional hygiene: a # developer running this may well have ROGUE_API_KEY set for their own install, and -# PROCESS ENV WINS over the env file by design - so without this the sandbox would -# authenticate to the local receiver with real credentials. The sh suite learned this +# the process env supplies every knob the env file in use does not set - so without +# this the sandbox could run with the developer's own values. The sh suite learned this # the hard way; same reasoning, same fix. $shipperKnobs = @('ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME', 'ROGUE_LOG_FILE', 'ROGUE_LOG_DIR', 'ROGUE_LOG_MAX_BYTES', diff --git a/tests/e2e_ship_logs.sh b/tests/e2e_ship_logs.sh index b06be2f..0c85b19 100644 --- a/tests/e2e_ship_logs.sh +++ b/tests/e2e_ship_logs.sh @@ -24,8 +24,8 @@ REPO="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" # Scrub every knob the shipper reads from the environment. NOT optional hygiene: a # developer running this almost certainly has ROGUE_API_KEY exported for their own -# install, and PROCESS ENV WINS over the env file by design - so without this the -# sandbox would authenticate with the developer's real credentials (and a leaked +# install, and the process env supplies every knob the env file in use does not set +# - so without this the sandbox could run with the developer's own values (and a leaked # ROGUE_LOG_DIR would point the "sandboxed" run at their real logs). Found the hard # way: the receiver logged a rejected key that this script never set. unset ROGUE_API_KEY ROGUE_BASE_URL ROGUE_ACTOR_EMAIL ROGUE_ACTOR_NAME \ diff --git a/tests/log_probe.ps1 b/tests/log_probe.ps1 index 7ad633c..8486427 100644 --- a/tests/log_probe.ps1 +++ b/tests/log_probe.ps1 @@ -8,8 +8,8 @@ # functions ($logFile, Log, Rotate-Log) can never collide in one session. # # -Creds takes JSON rather than a hashtable because it crosses a process boundary. -# It stands in for the merged map each dispatcher builds from -# /env → /etc/rogue|ProgramData → ~/.rogue-env → process env: the point of +# It stands in for the map each dispatcher builds from the process env plus the +# first env file holding ROGUE_API_KEY: the point of # the test is that Initialize-Logging reads THAT map, not $env: directly, which is # what lets an env file relocate the log on Windows. param( diff --git a/tests/test_env_first_found.mjs b/tests/test_env_first_found.mjs new file mode 100644 index 0000000..d2b5f54 --- /dev/null +++ b/tests/test_env_first_found.mjs @@ -0,0 +1,200 @@ +// tests/test_env_first_found.mjs — the env file rule on the Gemini readers: the +// first of machine (/etc/rogue/env) -> bundled (/env) -> user (~/.rogue-env) +// that holds ROGUE_API_KEY is used ALONE, and its values override the process env. +// Covers shared.mjs's loadEnvFiles (hook.mjs + heartbeat.mjs), hook.mjs end to end, +// and ship-logs.mjs's own loader through main(). +// +// The scripts are copied into a sandbox with the machine path literal redirected - +// the only way to stage that candidate without root. Each case gets its own copy, +// so the module-level HOME constant is re-evaluated and nothing is cached across. +// node --test tests/test_env_first_found.mjs +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPTS = path.join(REPO, "plugins", "gemini", "scripts"); +const MACHINE_EXPR = 'IS_WIN ? "C:\\\\ProgramData\\\\rogue\\\\env" : "/etc/rogue/env"'; + +// A sandbox: /scripts/*.mjs with the machine path pointing at /machine-env, +// plus an empty HOME. Returns the three candidate paths and the copied script dir. +function sandbox() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rogue-envff-")); + const scripts = path.join(root, "scripts"); + const home = path.join(root, "home"); + fs.mkdirSync(scripts); + fs.mkdirSync(path.join(home, ".rogue", "logs"), { recursive: true }); + const machine = path.join(root, "machine-env"); + let redirected = 0; + for (const f of fs.readdirSync(SCRIPTS)) { + if (!f.endsWith(".mjs")) continue; + let text = fs.readFileSync(path.join(SCRIPTS, f), "utf8"); + if (text.includes(MACHINE_EXPR)) { + text = text.split(MACHINE_EXPR).join(JSON.stringify(machine)); + redirected++; + } + fs.writeFileSync(path.join(scripts, f), text); + } + assert.equal(redirected, 2, "shared.mjs and ship-logs.mjs both name the machine env file"); + return { + root, + scripts, + home, + machine, + bundled: path.join(root, "env"), + user: path.join(home, ".rogue-env"), + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} + +const write = (file, lines) => fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 }); + +// loadEnvFiles() from the sandbox copy, with HOME and the ROGUE_* process env staged. +async function resolve(sb, processEnv) { + const saved = {}; + for (const k of ["HOME", "USERPROFILE", "ROGUE_API_KEY", "ROGUE_BASE_URL", "ROGUE_ACTOR_EMAIL"]) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env.HOME = sb.home; + process.env.USERPROFILE = sb.home; + Object.assign(process.env, processEnv); + try { + const mod = await import(pathToFileURL(path.join(sb.scripts, "shared.mjs")).href); + return mod.loadEnvFiles(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +test("loadEnvFiles: the machine file wins with all three present, nothing merged", async () => { + const sb = sandbox(); + try { + write(sb.machine, ["export ROGUE_API_KEY=machine-key", "export ROGUE_BASE_URL=http://machine.invalid"]); + write(sb.bundled, ["export ROGUE_API_KEY=bundled-key", "export ROGUE_BASE_URL=http://bundled.invalid"]); + write(sb.user, ["export ROGUE_API_KEY=user-key", "export ROGUE_BASE_URL=http://user.invalid", "export ROGUE_ACTOR_EMAIL=user@example.com"]); + const env = await resolve(sb, { ROGUE_API_KEY: "process-key" }); + assert.equal(env.ROGUE_API_KEY, "machine-key"); + assert.equal(env.ROGUE_BASE_URL, "http://machine.invalid"); + assert.equal(env.ROGUE_ACTOR_EMAIL, undefined, "a setting only the user file carries has no effect"); + } finally { + sb.cleanup(); + } +}); + +test("loadEnvFiles: a machine file without ROGUE_API_KEY is skipped whole", async () => { + const sb = sandbox(); + try { + write(sb.machine, ["export ROGUE_BASE_URL=http://machine.invalid"]); + write(sb.bundled, ["export ROGUE_API_KEY=bundled-key", "export ROGUE_BASE_URL=http://bundled.invalid"]); + write(sb.user, ["export ROGUE_API_KEY=user-key"]); + const env = await resolve(sb, {}); + assert.equal(env.ROGUE_API_KEY, "bundled-key"); + assert.equal(env.ROGUE_BASE_URL, "http://bundled.invalid"); + } finally { + sb.cleanup(); + } +}); + +test("loadEnvFiles: the chosen file overrides the process env; unset keys are kept", async () => { + const sb = sandbox(); + try { + write(sb.user, ["export ROGUE_API_KEY=user-key"]); + const env = await resolve(sb, { ROGUE_API_KEY: "process-key", ROGUE_BASE_URL: "http://process.invalid" }); + assert.equal(env.ROGUE_API_KEY, "user-key"); + assert.equal(env.ROGUE_BASE_URL, "http://process.invalid"); + } finally { + sb.cleanup(); + } +}); + +test("loadEnvFiles: with no file holding a key, the process env remains", async () => { + const sb = sandbox(); + try { + const env = await resolve(sb, { ROGUE_API_KEY: "process-key" }); + assert.equal(env.ROGUE_API_KEY, "process-key"); + } finally { + sb.cleanup(); + } +}); + +// hook.mjs end to end: the key that reaches the wire is the machine file's. +test("hook.mjs sends the machine file's key, not the user file's or the process env's", async () => { + const sb = sandbox(); + const seen = {}; + const server = http.createServer((req, res) => { + if ((req.url || "").endsWith("/hooks/gemini")) seen.key = req.headers["x-rogue-api-key"]; + req.on("data", () => {}); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end("{}"); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const base = `http://127.0.0.1:${server.address().port}`; + try { + write(sb.machine, ["export ROGUE_API_KEY=machine-key", `export ROGUE_BASE_URL=${base}`]); + write(sb.bundled, ["export ROGUE_API_KEY=bundled-key", `export ROGUE_BASE_URL=${base}`]); + write(sb.user, ["export ROGUE_API_KEY=user-key", `export ROGUE_BASE_URL=${base}`]); + const out = await new Promise((resolveOut) => { + const child = spawn(process.execPath, [path.join(sb.scripts, "hook.mjs"), "BeforeTool"], { + env: { PATH: process.env.PATH, HOME: sb.home, USERPROFILE: sb.home, ROGUE_API_KEY: "process-key" }, + }); + let stdout = ""; + child.stdout.on("data", (c) => (stdout += c)); + child.on("close", () => resolveOut(stdout)); + child.stdin.end('{"tool_name":"run_shell_command"}'); + }); + assert.equal(out, "{}", "the hook relayed the server body"); + assert.equal(seen.key, "machine-key"); + } finally { + server.close(); + sb.cleanup(); + } +}); + +// ship-logs.mjs keeps its own loader; hold it to the same rule through main(). +test("ship-logs.mjs uploads with the machine file's key and skips a keyless machine file", async () => { + for (const { machineLines, expected } of [ + { machineLines: ["export ROGUE_API_KEY=machine-key"], expected: "machine-key" }, + { machineLines: ["export ROGUE_BASE_URL=http://machine.invalid"], expected: "bundled-key" }, + ]) { + const sb = sandbox(); + const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, ROGUE_API_KEY: process.env.ROGUE_API_KEY }; + const savedFetch = globalThis.fetch; + try { + write(sb.machine, machineLines); + write(sb.bundled, ["export ROGUE_API_KEY=bundled-key"]); + write(sb.user, ["export ROGUE_API_KEY=user-key"]); + fs.writeFileSync(path.join(sb.home, ".rogue", "logs", "gemini.log"), "2026-01-01T00:00:00Z provider=gemini event=BeforeTool\n"); + process.env.HOME = sb.home; + process.env.USERPROFILE = sb.home; + process.env.ROGUE_API_KEY = "process-key"; + process.env.ROGUE_ACTOR_EMAIL = "amos@example.com"; + let sentKey = null; + globalThis.fetch = async (_url, opts) => { + sentKey = opts.headers["x-rogue-api-key"]; + return { status: 200, ok: true }; + }; + const shipper = await import(pathToFileURL(path.join(sb.scripts, "ship-logs.mjs")).href); + await shipper.main([sb.root, "gemini", "9.9.9", "gemini"]); + assert.equal(sentKey, expected); + } finally { + globalThis.fetch = savedFetch; + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + delete process.env.ROGUE_ACTOR_EMAIL; + sb.cleanup(); + } + } +}); diff --git a/tests/test_env_first_found.ps1 b/tests/test_env_first_found.ps1 new file mode 100644 index 0000000..cb26a85 --- /dev/null +++ b/tests/test_env_first_found.ps1 @@ -0,0 +1,154 @@ +#!/usr/bin/env pwsh +# tests/test_env_first_found.ps1 - the env file rule on the PowerShell readers: the +# first of machine (C:\ProgramData\rogue\env) -> bundled (\env) -> user +# (%USERPROFILE%\.rogue-env) that holds ROGUE_API_KEY is used ALONE, and its values +# override the process env. +# +# The loaders reachable through the ROGUE_PS_LIB_ONLY seam (the shared shipper's +# Import-ShipEnv, antigravity's and kiro's Import-Credentials) are exercised for real, +# with the machine path literal redirected into the sandbox by editing the script TEXT +# before it is dot-sourced - the only way to stage that candidate without admin +# rights. The dispatchers whose credential block runs at file scope, past the seam, +# get a structural check on the same three properties instead. +# +# pwsh -NoProfile -File tests/test_env_first_found.ps1 + +$ErrorActionPreference = 'Stop' +$repo = Split-Path -Parent (Split-Path -Parent $PSCommandPath) +$script:fails = 0 +$script:count = 0 +function Check { + param([string]$Label, $Expected, $Actual) + $script:count++ + if ("$Expected" -ceq "$Actual") { Write-Host " ok: $Label" } + else { Write-Host "FAIL: $Label (expected [$Expected], got [$Actual])"; $script:fails++ } +} + +$sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ('rogue-envff-' + [guid]::NewGuid().ToString('N')) +$machine = Join-Path $sandbox 'machine-env' +$sbHome = Join-Path $sandbox 'home' +$root = Join-Path $sandbox 'root' +New-Item -ItemType Directory -Path $sbHome -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $root 'scripts') -Force | Out-Null +Copy-Item (Join-Path $repo 'scripts/shared/env-file.ps1') (Join-Path $root 'scripts/env-file.ps1') +$bundled = Join-Path $root 'env' +$user = Join-Path $sbHome '.rogue-env' +$utf8 = New-Object System.Text.UTF8Encoding($false) + +function Set-EnvFile { param([string]$Path, [string[]]$Lines) + [System.IO.File]::WriteAllText($Path, (($Lines -join "`n") + "`n"), $utf8) +} +function Clear-EnvFiles { foreach ($f in @($machine, $bundled, $user)) { Remove-Item -LiteralPath $f -Force -ErrorAction SilentlyContinue } } + +$saved = @{} +foreach ($k in 'USERPROFILE', 'HOME', 'ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', + 'ROGUE_ACTOR_NAME', 'ROGUE_PS_LIB_ONLY', 'CLAUDE_PLUGIN_ROOT') { + $saved[$k] = [Environment]::GetEnvironmentVariable($k) +} +function Set-ProcessEnv { param([hashtable]$Values) + foreach ($k in 'ROGUE_API_KEY', 'ROGUE_BASE_URL', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME') { + [Environment]::SetEnvironmentVariable($k, $null) + } + foreach ($k in $Values.Keys) { [Environment]::SetEnvironmentVariable($k, $Values[$k]) } +} + +# Dot-sources one reader with its machine path redirected, then runs its loader and +# returns the resolved map. Re-sourced per case: each file defines the same function +# names, and the file-scope statements must run in the shipped order every time. +function Resolve-With { param([string]$Rel, [string]$Loader) + $text = Get-Content -Raw -LiteralPath (Join-Path $repo $Rel) + $literal = "'C:\ProgramData\rogue\env'" + if (-not $text.Contains($literal)) { throw "$Rel does not name the machine env file" } + $text = $text.Replace($literal, "'" + $machine + "'") + $script:creds = @{} + . ([scriptblock]::Create($text)) + $ErrorActionPreference = 'Stop' + # Plain assignment, not $script: - the dot-sourced param block declared an empty + # $PluginRoot in THIS scope, and that is the one the loader would see. + $PluginRoot = $root + $script:pluginRoot = $root + & $Loader + return $script:creds +} + +$readers = @( + @{ rel = 'scripts/shared/ship-logs.ps1'; loader = 'Import-ShipEnv' }, + @{ rel = 'plugins/antigravity/scripts/hook.ps1'; loader = 'Import-Credentials' }, + @{ rel = 'plugins/antigravity/scripts/heartbeat.ps1'; loader = 'Import-Credentials' }, + @{ rel = 'plugins/kiro/scripts/heartbeat.ps1'; loader = 'Import-Credentials' }) + +try { + $env:USERPROFILE = $sbHome + $env:HOME = $sbHome + $env:ROGUE_PS_LIB_ONLY = '1' + $env:CLAUDE_PLUGIN_ROOT = $root + + foreach ($r in $readers) { + Write-Host "== $($r.rel)" + + # All three files carry a key: the machine file alone configures the reader, + # and the user file's base URL has no effect. + Clear-EnvFiles + Set-EnvFile $machine @('export ROGUE_API_KEY=machine-key', 'export ROGUE_BASE_URL=http://machine.invalid') + Set-EnvFile $bundled @('export ROGUE_API_KEY=bundled-key', 'export ROGUE_BASE_URL=http://bundled.invalid') + Set-EnvFile $user @('export ROGUE_API_KEY=user-key', 'export ROGUE_BASE_URL=http://user.invalid') + Set-ProcessEnv @{ ROGUE_API_KEY = 'process-key' } + $m = Resolve-With $r.rel $r.loader + Check "$($r.loader): machine file wins with all three present" 'machine-key' $m['ROGUE_API_KEY'] + Check "$($r.loader): nothing merged from the user file" 'http://machine.invalid' $m['ROGUE_BASE_URL'] + + # A machine file without ROGUE_API_KEY is skipped whole; the bundled file is next. + Set-EnvFile $machine @('export ROGUE_BASE_URL=http://machine.invalid') + Set-ProcessEnv @{} + $m = Resolve-With $r.rel $r.loader + Check "$($r.loader): keyless machine file is skipped" 'bundled-key' $m['ROGUE_API_KEY'] + Check "$($r.loader): ...and contributes nothing" 'http://bundled.invalid' $m['ROGUE_BASE_URL'] + + # The chosen file overrides the process env; keys it does not set are kept. + Clear-EnvFiles + Set-EnvFile $user @('export ROGUE_API_KEY=user-key') + Set-ProcessEnv @{ ROGUE_API_KEY = 'process-key'; ROGUE_BASE_URL = 'http://process.invalid' } + $m = Resolve-With $r.rel $r.loader + Check "$($r.loader): the chosen file overrides the process env" 'user-key' $m['ROGUE_API_KEY'] + Check "$($r.loader): process env kept for keys the file lacks" 'http://process.invalid' $m['ROGUE_BASE_URL'] + + # No file holds a key: the process env is what remains. + Clear-EnvFiles + $m = Resolve-With $r.rel $r.loader + Check "$($r.loader): process env alone still configures" 'process-key' $m['ROGUE_API_KEY'] + } + + # The file-scope readers cannot be driven off-Windows, so hold their SOURCE to + # the rule: process env read first, machine path first in the candidate list, + # and a candidate skipped when it lacks the key. + Write-Host '== structural: file-scope readers' + foreach ($rel in 'plugins/rogue/scripts/hook.ps1', 'plugins/codex/scripts/hook.ps1', + 'plugins/copilot/scripts/hook.ps1', 'plugins/cursor/scripts/hook.ps1', + 'plugins/kiro/scripts/hook.ps1', 'plugins/rogue/scripts/heartbeat.ps1', + 'plugins/codex/scripts/heartbeat.ps1', 'plugins/copilot/scripts/heartbeat.ps1', + 'plugins/rogue/scripts/auto-update.ps1') { + $src = Get-Content -Raw -LiteralPath (Join-Path $repo $rel) + $machineAt = $src.IndexOf("'C:\ProgramData\rogue\env'") + $bundledAt = [regex]::Match($src, "Join-Path \`$(?:env:CLAUDE_PLUGIN_ROOT|pluginRoot|PluginRoot) 'env'").Index + $userAt = $src.IndexOf("'.rogue-env'") + Check "${rel}: machine, then bundled, then user" $true ($machineAt -ge 0 -and $machineAt -lt $bundledAt -and $bundledAt -lt $userAt) + Check "${rel}: a candidate without ROGUE_API_KEY is skipped" $true ` + ($src -match "if \(-not \`$(?:fileVals|vals)\['ROGUE_API_KEY'\]\) \{ (?:Dbg [^;]+; )?continue \}") + if ($rel -notlike '*auto-update.ps1') { + $procAt = $src.IndexOf("foreach (`$k in 'ROGUE_API_KEY'") + Check "${rel}: process env is read before the files" $true ($procAt -ge 0 -and $procAt -lt $machineAt) + } + } + $warn = Get-Content -Raw -LiteralPath (Join-Path $repo 'plugins/codex/scripts/warn.ps1') + Check 'codex warn.ps1: machine path first' $true ($warn.IndexOf("'C:\ProgramData\rogue\env'") -lt $warn.IndexOf("Join-Path `$pluginRoot 'env'")) + Check 'codex warn.ps1: stops at the first file with a key' $true ($warn -match 'if \(\$key\) \{ break \}') + Check 'codex warn.ps1: process env only when no file has a key' $true ($warn -match "if \(-not \`$key\) \{ \`$key = \[Environment\]::GetEnvironmentVariable\('ROGUE_API_KEY'\) \}") +} finally { + foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } + Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host '' +if ($script:fails -gt 0) { Write-Host "$script:fails of $script:count checks FAILED"; exit 1 } +Write-Host "all $script:count env-file first-found checks passed" +exit 0 diff --git a/tests/test_env_first_found.sh b/tests/test_env_first_found.sh new file mode 100644 index 0000000..e2a6ed0 --- /dev/null +++ b/tests/test_env_first_found.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# tests/test_env_first_found.sh — the env file rule on every sh reader: the first +# of machine (/etc/rogue/env) -> bundled (/env) -> user (~/.rogue-env) that +# holds ROGUE_API_KEY is used ALONE, and its values override the process env. +# +# Each plugin runs from a COPY whose /etc/rogue/env literal is redirected into the +# sandbox (the only way to stage the machine candidate without root), with a fake +# curl on PATH that records the request instead of sending it. env-file.sh is left +# untouched on purpose: its /etc/rogue/env case is the root-owner rule, not a read. +# +# TEST_SH=dash bash tests/test_env_first_found.sh +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +SH="${TEST_SH:-sh}" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +fails=0 +check() { #