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/README.md b/README.md index 655a53b..425427c 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,11 @@ scripts/setup.sh / setup.ps1 — credential storage helpers All hooks are `type: "command"`. Each event registers **two** entries — a POSIX `sh` one (`hook.sh`, for macOS/Linux/WSL) and a PowerShell one (`hook.ps1`, for native Windows) — and exactly one does real work per machine (`hook.sh` stands -down under Git Bash so the PowerShell entry owns Windows). They resolve -credentials from `${CLAUDE_PLUGIN_ROOT}/env` (bundled), `/etc/rogue/env` / -`C:\ProgramData\rogue\env` (MDM), or `~/.rogue-env` / `%USERPROFILE%\.rogue-env` -(per-user) at runtime, then POST the event payload to +down under Git Bash so the PowerShell entry owns Windows). They read one env file +at runtime — the first of `/etc/rogue/env` / `C:\ProgramData\rogue\env` (MDM), +`${CLAUDE_PLUGIN_ROOT}/env` (bundled), and `~/.rogue-env` / +`%USERPROFILE%\.rogue-env` (per-user) that holds `ROGUE_API_KEY` — then POST the +event payload to `https://api.rogue.security/api/v1/hooks/claude`. If `ROGUE_API_KEY` is empty, hooks return `{}` (allow) — fail-open by design, @@ -121,7 +122,8 @@ export ROGUE_ACTOR_NAME='Your Name' ``` System-wide MDM deployment can drop the same exports into `/etc/rogue/env` — -hooks check that path first. +hooks check that path first, and when it holds `ROGUE_API_KEY` they read no other +file. Values in the file in use override the process environment. To revoke: `rm ~/.rogue-env` (per-user) or `sudo rm /etc/rogue/env` (MDM). diff --git a/docs/deployment.md b/docs/deployment.md index 5cf6e85..a36ab9f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,7 +2,8 @@ How to roll out Rogue Security AIDR to a managed Claude Code fleet, using the Claude management UI for plugin distribution and an MDM (Kandji, Jamf, -etc.) for per-user identity provisioning. +etc.) to provision the machine env file: the org API key plus the assigned +user's identity. If you are an individual user installing for yourself, see the [README](../README.md) and run `/rogue:setup` instead — this guide doesn't apply. @@ -25,12 +26,12 @@ produce correctly-attributed events: │ ┌────────────────────┐ │ │ MDM (Kandji/Jamf) │ push script │ -│ + per-user vars │─────────────────────┤ +│ key + user vars │─────────────────────┤ └────────────────────┘ ▼ ┌────────────────────────┐ │ User Device │ │ ~/.claude/plugins/… │ (plugin) - │ /etc/rogue/env │ (identity) + │ /etc/rogue/env │ (key + identity) │ │ │ hook fires → POSTs │ │ org key + real actor │ @@ -67,7 +68,7 @@ curl -fsSL https://raw.githubusercontent.com/qualifire-dev/rogue-plugins/main/sc #!/usr/bin/env bash set -e [ -n "$USER_EMAIL" ] && [ -n "$USER_FULL_NAME" ] || exit 0 -ROGUE_ACTOR_EMAIL="$USER_EMAIL" ROGUE_ACTOR_NAME="$USER_FULL_NAME" \ +ROGUE_API_KEY="" ROGUE_ACTOR_EMAIL="$USER_EMAIL" ROGUE_ACTOR_NAME="$USER_FULL_NAME" \ bash <(curl -fsSL https://raw.githubusercontent.com/qualifire-dev/rogue-plugins/main/scripts/mdm-provision-actor.sh) # 4. On a test device, verify @@ -118,14 +119,19 @@ In your org's Claude management UI: 4. Push it to your user group(s). Users receive it on their next Claude Code session start. -At this point users have the plugin and the API key. Events will POST to -Rogue but with empty actor headers until Step 3 lands. +At this point users have the plugin and the API key. Until Step 3 lands the +hooks read the bundled `env`, and the actor is whatever the device's git config +or login name says. ## Step 3 — Deploy the MDM actor provisioning script `scripts/mdm-provision-actor.sh` writes `/etc/rogue/env` on the target -device with the assigned user's identity. The plugin hooks pick up that -file at hook-fire time. +device with the org API key and the assigned user's identity. It is the first +file the hooks look at, and once it holds `ROGUE_API_KEY` it is the only one +read: nothing from the bundled `env` is merged, so the script also pins +`ROGUE_AUTO_UPDATE=0` (pass `--mode block` if the bundle was compiled with +it). A file without the key is skipped whole, which is why the script refuses +to write one. ### Kandji (Custom Script) @@ -145,6 +151,7 @@ set -e [ -n "$USER_EMAIL" ] || exit 0 [ -n "$USER_FULL_NAME" ] || exit 0 +ROGUE_API_KEY="" \ ROGUE_ACTOR_EMAIL="$USER_EMAIL" \ ROGUE_ACTOR_NAME="$USER_FULL_NAME" \ bash <(curl -fsSL https://raw.githubusercontent.com/qualifire-dev/rogue-plugins/main/scripts/mdm-provision-actor.sh) @@ -162,28 +169,29 @@ Management → Scripts. Set the script parameter labels: - `Parameter 4` → "Email" - `Parameter 5` → "Full name" +- `Parameter 6` → "API key" Create a Policy that runs the script with the user's email and name passed -as parameters (typically populated by an LDAP/AD attribute mapping). The -script accepts `--email "$4" --name "$5"` natively: +as parameters (typically populated by an LDAP/AD attribute mapping) and the +org API key as the third: ```bash #!/usr/bin/env bash set -e EMAIL="$4" NAME="$5" -[ -n "$EMAIL" ] && [ -n "$NAME" ] || exit 0 +KEY="$6" +[ -n "$EMAIL" ] && [ -n "$NAME" ] && [ -n "$KEY" ] || exit 0 bash <(curl -fsSL https://raw.githubusercontent.com/qualifire-dev/rogue-plugins/main/scripts/mdm-provision-actor.sh) \ - --email "$EMAIL" --name "$NAME" + --email "$EMAIL" --name "$NAME" --key "$KEY" ``` ### Other MDMs -The script accepts identity via either env vars (`ROGUE_ACTOR_EMAIL`, -`ROGUE_ACTOR_NAME`) or CLI args (`--email`, `--name`). Use whichever your -MDM substitutes natively. Optional flags `--key`, `--mode`, `--base-url` -let MDM also push the API key or enforcement mode if you prefer -fully-centralized control over those. +The script accepts its inputs via either env vars (`ROGUE_API_KEY`, +`ROGUE_ACTOR_EMAIL`, `ROGUE_ACTOR_NAME`) or CLI args (`--key`, `--email`, +`--name`). Use whichever your MDM substitutes natively. `--mode`, +`--base-url` and `--auto-update` are optional. ### Offline / air-gapped fleets @@ -198,6 +206,7 @@ On a single test device after both deploys land: ```bash # 1. MDM landed ls -la /etc/rogue/env # expect: -rw-r--r-- root wheel ... +grep -c ROGUE_API_KEY /etc/rogue/env # expect: 1 (a keyless file is not read) grep ACTOR /etc/rogue/env # expect: ROGUE_ACTOR_EMAIL=alice@yourorg.com # 2. Plugin landed @@ -217,35 +226,32 @@ full pipeline is healthy. Every hook in the plugin runs this preamble before POSTing the event: ```sh -[ -r "${CLAUDE_PLUGIN_ROOT}/env" ] && . "${CLAUDE_PLUGIN_ROOT}/env" -[ -r /etc/rogue/env ] && . /etc/rogue/env -[ -r "$HOME/.rogue-env" ] && . "$HOME/.rogue-env" +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=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi +done ``` -Three credential sources, sourced in order. Later sources override earlier: +Three candidates. The first that holds `ROGUE_API_KEY` is sourced alone; its +values override the process environment, and a file without the key is skipped: | Source | Written by | Carries | | --- | --- | --- | -| `${CLAUDE_PLUGIN_ROOT}/env` | Compile script (Step 1) | Org API key, enforcement mode, auto-update pin | -| `/etc/rogue/env` | MDM script (Step 3) | Per-user actor identity (and optionally a per-machine key) | -| `~/.rogue-env` | User running `/rogue:setup` | Per-user override; not used in managed deployments | - -The hook payload comes out with: org API key (from bundle) + per-user actor -(from MDM) + org enforcement mode (from bundle). +| `/etc/rogue/env` | MDM script (Step 3) | Org API key, identity, mode, auto-update pin; first candidate | +| `${CLAUDE_PLUGIN_ROOT}/env` | Compile script (Step 1) | Org API key, enforcement mode, auto-update pin; read until Step 3 lands | +| `~/.rogue-env` | User running `/rogue:setup` | Per-user; not used in managed deployments | ## Operations ### Rotating the API key -**Standard path** — recompile, re-upload to the Claude management UI. All -devices pick up the new key on next plugin sync (typically next session -start). +**MDM** — push the new `--key` through the MDM script. The next enforcement +cycle rewrites `/etc/rogue/env`, and every hook fire after that uses the new key; +revoke the old one in the dashboard afterward. -**Emergency path** — push a new `--key` value through the MDM script. Since -`/etc/rogue/env` is sourced *after* the bundle, the MDM-supplied key wins -on every hook fire. Useful if you suspect the bundled key is compromised -and need same-hour mitigation; revoke the old key in the dashboard -immediately afterward. +**Bundle** — recompile and re-upload as well, so a device the MDM has not +reached yet does not keep posting with the old key. ### Shipping plugin updates @@ -277,26 +283,25 @@ overwrites `/etc/rogue/env` with the new identity. No manual cleanup. | Symptom | Likely cause | Fix | | --- | --- | --- | -| `/rogue:status` shows the *compiler's* identity (your IT lead's email) | MDM script didn't run yet; plugin fell back to compile-time git config | Force MDM enforcement: Kandji "Run library item now", Jamf `sudo jamf policy` | +| `/rogue:status` shows the user's git identity, not the MDM-assigned one | MDM script didn't run yet, so the hooks read the bundled `env` (`/rogue:status` marks `/etc/rogue/env` "not read" when it lacks the key) | Force MDM enforcement: Kandji "Run library item now", Jamf `sudo jamf policy` | | `/rogue:status` shows blank identity | MDM ran with empty placeholders, or fell back to a no-op | Verify MDM user binding; confirm the `[ -n "$USER_EMAIL" ]` guard in your payload | | `/rogue:status` says "not configured" | Plugin didn't deploy, or `${CLAUDE_PLUGIN_ROOT}/env` was stripped | Re-upload via Claude management UI; verify zip has `env` at root | -| Events in dashboard have blank actor | Plugin landed before MDM script (race during rollout) | Wait for next MDM enforcement cycle, or kick it manually | +| Events in dashboard carry the user's git identity, not the MDM-assigned one | Plugin landed before MDM script (race during rollout): the bundled `env` is read until `/etc/rogue/env` holds the key | Wait for next MDM enforcement cycle, or kick it manually | | No events at all in dashboard | Hooks fail-open silently on curl timeout or network error | Check device can reach `api.rogue.security`; inspect `~/.rogue/auto-update.log` for clues | | macOS modal alert doesn't fire on blocked prompts | Expected on every surface except **Claude Cowork local** — the CLI and the Desktop app render the block reason natively and are suppressed deliberately. Otherwise: a cloud Cowork session (`CLAUDE_CODE_REMOTE=true`, a headless container with no GUI), `ROGUE_ALERT=0`, an event excluded by `ROGUE_ALERT_EVENTS`, or no `osascript` on `PATH` | Read `~/.rogue/hook.log`: `alert_skipped=1` names the gate input that declined (`entrypoint=` / `cowork=` / `remote=` / `agent=`), and `alert_rc=` (plus `alert_err="…"`) reports a modal that was attempted and failed. **No Automation permission is needed** — the alert does not use `tell application "System Events"`, so a Privacy & Security → Automation grant is not the fix | | Plugin upload rejected by Claude management UI | Hooks file declares unsupported events, or marketplace.json missing | Recompile with the latest `compile-customer-plugin.sh` — the script filters hooks and generates marketplace.json | ## Security notes -- **Org-wide API key.** The compiled bundle carries a single API key shared - by every user it's pushed to. Per-user attribution comes from the actor - headers (set by MDM), not from per-user keys. If you require true - per-user keys, deploy them via MDM by passing `--key` per device — but - this means revocation must also happen via MDM, not via re-compile. +- **Org-wide API key.** The bundle and `/etc/rogue/env` both carry a single + API key shared by every user. Per-user attribution comes from the actor + headers (set by MDM), not from per-user keys. Per-device keys are possible + (`--key` per device), but then revocation happens via MDM, not re-compile. -- **`/etc/rogue/env` is world-readable by default** (`0644`, root-owned). - If your MDM script writes the API key here too, tighten to `0640` and - add a `_rogue` group whose members are the human users you want to read - it. Modify the script's `chmod` line accordingly. +- **`/etc/rogue/env` is world-readable** (`0644`, root-owned): the hooks run + as each user and must read it, and it carries the org key. To narrow + readers, change the script's `chmod` to `0640` and add a group whose + members are the human users. - **The compiled zip is sensitive.** Anyone with the file can extract the API key in cleartext. Distribute only through the Claude management UI; @@ -317,8 +322,8 @@ overwrites `/etc/rogue/env` with the new identity. No manual cleanup. - **Hot-desk / shared devices.** This guide assumes one identity per device. For machines where multiple users sign in over time, identity provisioning should happen at user login (LaunchAgent, PAM hook) and - write `~/.rogue-env` instead of `/etc/rogue/env`. Contact Rogue support - for a reference setup. + write `~/.rogue-env`, with `/etc/rogue/env` absent: a machine file holding a + key is read alone. Contact Rogue support for a reference setup. - **Non-managed installs.** Users installing the plugin themselves via the public marketplace should follow the [README](../README.md) and run diff --git a/docs/log-shipping.md b/docs/log-shipping.md index 9dffe4c..9245cf5 100644 --- a/docs/log-shipping.md +++ b/docs/log-shipping.md @@ -135,10 +135,10 @@ which is literally "a task that runs on a single computer in a fleet". **Which files.** Resolve the log directory the same way the dispatchers do, or the agent reads a path nothing writes to: -1. `ROGUE_LOG_DIR` / `ROGUE_LOG_FILE` from the shared env-file chain - (`/etc/rogue/env` or `C:\ProgramData\rogue\env`, then `~/.rogue-env`) — the - MDM files are the ones that matter here, and phase 1 made all eleven - dispatchers honor them. +1. `ROGUE_LOG_DIR` / `ROGUE_LOG_FILE` from the env file in use (the first of + `/etc/rogue/env` or `C:\ProgramData\rogue\env`, the bundled `env`, and + `~/.rogue-env` that holds `ROGUE_API_KEY`) — the MDM file is the one that + matters here, and phase 1 made all eleven dispatchers honor it. 2. Otherwise `~/.rogue/logs/` (`%USERPROFILE%\.rogue\logs\`). **`ROGUE_LOG_FILE` is an exact path and takes precedence over the glob** — when it diff --git a/docs/plugin-log-shipper.md b/docs/plugin-log-shipper.md index 202e663..b14ffd5 100644 --- a/docs/plugin-log-shipper.md +++ b/docs/plugin-log-shipper.md @@ -116,11 +116,11 @@ is still derived from `$0`/`$PSCommandPath` so the bundled `env` is not skipped. ```text 1. Git Bash stand-down: uname = MINGW*/MSYS*/CYGWIN* → exit 0 (ps1 owns Windows) - 2. load env files, later wins — the SAME platform-aware chain the dispatchers use: - /env + 2. load ONE env file, the first holding ROGUE_API_KEY — the SAME rule the dispatchers use: /etc/rogue/env (POSIX) | C:\ProgramData\rogue\env (Windows, MDM) + /env $HOME/.rogue-env (POSIX) | %USERPROFILE%\.rogue-env (Windows) - process env wins over all files + its values override the process env 3. no ROGUE_API_KEY → exit 0 4. resolve which log file(s) to ship — own slug only by default 5. mkdir -p ~/.rogue/ship @@ -974,8 +974,8 @@ failed one. ## Environment knobs -All resolved from the shared env-file chain, so `/etc/rogue/env` can set them -fleet-wide, and process env still wins: +All resolved from the env file in use, so `/etc/rogue/env` can set them +fleet-wide; the process env supplies what that file does not set: | var | default | meaning | |---|---|---| 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..681cf3f 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=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done [ "$BASE_URL_EXPLICIT" = "1" ] && ROGUE_BASE_URL="$flag_base_url" @@ -825,8 +829,9 @@ write_statusline_script() { # Rogue Security status badge (installed by install.sh). Status circle then # teal bracketed label: 🟢 [Rogue Security] configured, 🔴 [Rogue Security] not. set -u +# Presence only: the badge never executes an env file. for f in /etc/rogue/env "$HOME/.rogue-env"; do - [ -r "$f" ] && . "$f" + if [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$f"; then ROGUE_API_KEY=found; break; fi done if [ -n "${ROGUE_API_KEY:-}" ]; then dot='🟢' diff --git a/plugins/antigravity/README.md b/plugins/antigravity/README.md index 2952a6d..ef6805c 100644 --- a/plugins/antigravity/README.md +++ b/plugins/antigravity/README.md @@ -30,9 +30,9 @@ a flagged model turn is terminated at `PostInvocation`. back to `{}`. - **Shared credentials.** Reads `~/.rogue-env` (mode 600) from disk each invocation — the SAME file used by the Claude Code, Codex, Cursor, and - Gemini CLI Rogue plugins. Env precedence (later wins): `/env` - → `/etc/rogue/env` (`C:\ProgramData\rogue\env` on Windows) → - `~/.rogue-env`. + Gemini CLI Rogue plugins. One env file is read: the first of `/etc/rogue/env` + (`C:\ProgramData\rogue\env` on Windows), `/env`, and + `~/.rogue-env` that holds `ROGUE_API_KEY`. - **Version** lives in the bundled `VERSION` file at the plugin root (the Antigravity `plugin.json` schema has no `version` field). @@ -68,8 +68,8 @@ Run `/status`. You should see HTTP 200 against the ping endpoint, your active rulesets, and a tail of recent hook activity (`~/.rogue/logs/antigravity.log` — each Rogue plugin logs to its own file, capped at 10 MiB with one `.1` rotation kept). `ROGUE_LOG_MAX_BYTES` overrides that cap and `0` turns rotation off; -`ROGUE_LOG_FILE` / `ROGUE_LOG_DIR` relocate the log. All three are read from -`~/.rogue-env` (or `/etc/rogue/env`), with the process environment winning. +`ROGUE_LOG_FILE` / `ROGUE_LOG_DIR` relocate the log. All three are read from the +env file in use, which overrides the process environment. ## Uninstall diff --git a/plugins/antigravity/scripts/env-file.ps1 b/plugins/antigravity/scripts/env-file.ps1 index f0155c9..c24662e 100644 --- a/plugins/antigravity/scripts/env-file.ps1 +++ b/plugins/antigravity/scripts/env-file.ps1 @@ -21,12 +21,14 @@ function Test-RogueEnvFile { $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $admins = @('S-1-5-18', 'S-1-5-32-544') $trusted = @($admins) + $user + # The machine file accepts only SYSTEM and Administrators as writers; the user file also accepts its owner. + $writers = if ($System) { $admins } else { $trusted } $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value if ($owner -notin $trusted -or ($System -and $owner -notin $admins)) { return $false } $write = [System.Security.AccessControl.FileSystemRights]'Write, Delete, ChangePermissions, TakeOwnership, DeleteSubdirectoriesAndFiles' foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $write) -and - $rule.IdentityReference.Value -notin $trusted) { return $false } + $rule.IdentityReference.Value -notin $writers) { return $false } } return $true } catch { return $false } diff --git a/plugins/antigravity/scripts/heartbeat.ps1 b/plugins/antigravity/scripts/heartbeat.ps1 index d22d901..e319766 100644 --- a/plugins/antigravity/scripts/heartbeat.ps1 +++ b/plugins/antigravity/scripts/heartbeat.ps1 @@ -112,21 +112,27 @@ 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'))) { - if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } - 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()) - } - } - } - # 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. + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } 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 trusted 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_]*)=(.*)$') { + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + } + } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..bbd1b7b 100644 --- a/plugins/antigravity/scripts/heartbeat.sh +++ b/plugins/antigravity/scripts/heartbeat.sh @@ -39,11 +39,17 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + _env_lib="$(dirname -- "$0")/env-file.sh" + if [ -r "$_env_lib" ]; then + . "$_env_lib" + 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=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done + fi # 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..25b549b 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,23 +244,30 @@ 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'))) { - if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } - 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()) - } - } - } - # 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. + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } 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 trusted 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_]*)=(.*)$') { + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + } + } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..4ac45a9 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,20 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. + _env_lib="$(dirname -- "$0")/env-file.sh" + if [ -r "$_env_lib" ]; then + . "$_env_lib" + 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=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done + fi # 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..ba62e78 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', @@ -331,23 +332,30 @@ $SHIP_ENV_VARS = @( function Import-ShipEnv { $envLibrary = Join-Path $PluginRoot 'scripts/env-file.ps1' if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } $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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..1eb3152 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=[\"']?[^\"'[:space:]]" "$_env_file" 2>/dev/null; then + . "$_env_file" 2>/dev/null; break + fi done return 0 } diff --git a/plugins/antigravity/skills/status/SKILL.md b/plugins/antigravity/skills/status/SKILL.md index 8ec1242..a70c43a 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,39 @@ 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=[\"']?[^\"'[:space:]]" "$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)" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$f" && echo " $f (no ROGUE_API_KEY, not read)" +done +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' + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$') { + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 +61,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=[\"']?[^\"'[:space:]]" "$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 +102,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=[\"']?[^\"'[:space:]]" "$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 +124,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=[\"']?[^\"'[:space:]]" "$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 +153,28 @@ 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. -foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +# Mirror the dispatcher's rule: the first of C:\ProgramData\rogue\env (MDM), the +# plugin's bundled env 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. +$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 @('C:\ProgramData\rogue\env', $pluginEnv.FullName, (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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 +235,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..aea8056 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=[\"']?[^\"'[:space:]]" "$f" && { . "$f"; ROGUE_ENV_IN_USE=$f; break; } +done EOF chmod +x /tmp/rogue-source-env.sh @@ -34,6 +36,10 @@ 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)" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$f" && echo " $f (no ROGUE_API_KEY, not read)" +done +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 +85,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=[\"']?[^\"'[:space:]]" "$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 +116,28 @@ 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. -foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +# Mirror the dispatcher's rule: the first of C:\ProgramData\rogue\env (MDM), the +# plugin's bundled env 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. +$pluginEnv = Get-ChildItem "$env:USERPROFILE\.codex\plugins" -Recurse -Filter env -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like '*rogue*' } | Select-Object -First 1 +foreach ($f in @('C:\ProgramData\rogue\env', $pluginEnv.FullName, (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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 +198,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 +226,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/env-file.ps1 b/plugins/codex/scripts/env-file.ps1 index f0155c9..c24662e 100644 --- a/plugins/codex/scripts/env-file.ps1 +++ b/plugins/codex/scripts/env-file.ps1 @@ -21,12 +21,14 @@ function Test-RogueEnvFile { $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $admins = @('S-1-5-18', 'S-1-5-32-544') $trusted = @($admins) + $user + # The machine file accepts only SYSTEM and Administrators as writers; the user file also accepts its owner. + $writers = if ($System) { $admins } else { $trusted } $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value if ($owner -notin $trusted -or ($System -and $owner -notin $admins)) { return $false } $write = [System.Security.AccessControl.FileSystemRights]'Write, Delete, ChangePermissions, TakeOwnership, DeleteSubdirectoriesAndFiles' foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $write) -and - $rule.IdentityReference.Value -notin $trusted) { return $false } + $rule.IdentityReference.Value -notin $writers) { return $false } } return $true } catch { return $false } diff --git a/plugins/codex/scripts/heartbeat.ps1 b/plugins/codex/scripts/heartbeat.ps1 index f9cb82b..8dc1c59 100644 --- a/plugins/codex/scripts/heartbeat.ps1 +++ b/plugins/codex/scripts/heartbeat.ps1 @@ -88,20 +88,27 @@ 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'))) { - if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } - 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()) - } - } -} -# 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. +# Fail open: with no readable helper, leave a no-op reader behind so the env +# files are skipped instead of the whole credential block dying on the load. +try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } +catch { function Read-RogueEnvFile { param([string]$Path) } } 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 trusted 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_]*)=(.*)$') { + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + } + } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 # before the API-key check below so the ordering cannot regress into reading it too diff --git a/plugins/codex/scripts/heartbeat.sh b/plugins/codex/scripts/heartbeat.sh index d66fb0e..a0fbf03 100755 --- a/plugins/codex/scripts/heartbeat.sh +++ b/plugins/codex/scripts/heartbeat.sh @@ -35,10 +35,16 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + 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=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done +fi # 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..f8ee877 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,22 +179,29 @@ 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'))) { - if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } - 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()) - } - } -} -# 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. +# Fail open: with no readable helper, leave a no-op reader behind so the env +# files are skipped instead of the whole credential block dying on the load. +try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } +catch { function Read-RogueEnvFile { param([string]$Path) } } 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 trusted 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_]*)=(.*)$') { + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + } + } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { continue } + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break +} # Logging is initialised HERE - after the credential files are parsed, so they can # relocate the log - but BEFORE the API-key check below, so an unconfigured diff --git a/plugins/codex/scripts/hook.sh b/plugins/codex/scripts/hook.sh index 034545a..517f6f7 100755 --- a/plugins/codex/scripts/hook.sh +++ b/plugins/codex/scripts/hook.sh @@ -14,10 +14,16 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + 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=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done +fi # 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..ba62e78 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', @@ -331,23 +332,30 @@ $SHIP_ENV_VARS = @( function Import-ShipEnv { $envLibrary = Join-Path $PluginRoot 'scripts/env-file.ps1' if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } $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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..1eb3152 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=[\"']?[^\"'[:space:]]" "$_env_file" 2>/dev/null; then + . "$_env_file" 2>/dev/null; break + fi done return 0 } diff --git a/plugins/codex/scripts/warn.ps1 b/plugins/codex/scripts/warn.ps1 index 32a667a..eb88723 100644 --- a/plugins/codex/scripts/warn.ps1 +++ b/plugins/codex/scripts/warn.ps1 @@ -5,20 +5,23 @@ $ErrorActionPreference = 'SilentlyContinue' if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { exit 0 } $pluginRoot = $env:PLUGIN_ROOT +# Fail open: with no readable helper, leave a no-op reader behind so the env +# files are skipped instead of the whole credential block dying on the load. +try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } +catch { function Read-RogueEnvFile { param([string]$Path) } } -# 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 trusted 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('"') } + foreach ($line in (Read-RogueEnvFile $f)) { + 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..780727e 100755 --- a/plugins/codex/scripts/warn.sh +++ b/plugins/codex/scripts/warn.sh @@ -4,8 +4,15 @@ # 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + 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=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done +fi [ -n "${ROGUE_API_KEY:-}" ] || printf '{"systemMessage": "[Rogue Security] Not configured. Run /rogue:setup to connect your API key."}' diff --git a/plugins/copilot/README.md b/plugins/copilot/README.md index cce34c6..fa274d7 100644 --- a/plugins/copilot/README.md +++ b/plugins/copilot/README.md @@ -69,6 +69,7 @@ Copilot CLI, and run `/rogue:status` to verify. ## Credentials -Shared `~/.rogue-env` (mode 600), read from disk at each invocation with the -precedence `${PLUGIN_ROOT}/env` → `/etc/rogue/env` (`C:\ProgramData\rogue\env`) → -`~/.rogue-env`. The same file is used by every Rogue plugin. +Shared `~/.rogue-env` (mode 600), read from disk at each invocation. One env file +is used: the first of `/etc/rogue/env` (`C:\ProgramData\rogue\env`), +`${PLUGIN_ROOT}/env`, and `~/.rogue-env` that holds `ROGUE_API_KEY`. The same file +is used by every Rogue plugin. diff --git a/plugins/copilot/scripts/env-file.ps1 b/plugins/copilot/scripts/env-file.ps1 index f0155c9..c24662e 100644 --- a/plugins/copilot/scripts/env-file.ps1 +++ b/plugins/copilot/scripts/env-file.ps1 @@ -21,12 +21,14 @@ function Test-RogueEnvFile { $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $admins = @('S-1-5-18', 'S-1-5-32-544') $trusted = @($admins) + $user + # The machine file accepts only SYSTEM and Administrators as writers; the user file also accepts its owner. + $writers = if ($System) { $admins } else { $trusted } $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value if ($owner -notin $trusted -or ($System -and $owner -notin $admins)) { return $false } $write = [System.Security.AccessControl.FileSystemRights]'Write, Delete, ChangePermissions, TakeOwnership, DeleteSubdirectoriesAndFiles' foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $write) -and - $rule.IdentityReference.Value -notin $trusted) { return $false } + $rule.IdentityReference.Value -notin $writers) { return $false } } return $true } catch { return $false } diff --git a/plugins/copilot/scripts/heartbeat.ps1 b/plugins/copilot/scripts/heartbeat.ps1 index e0a6c0f..7d4b2d8 100644 --- a/plugins/copilot/scripts/heartbeat.ps1 +++ b/plugins/copilot/scripts/heartbeat.ps1 @@ -90,20 +90,27 @@ 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'))) { - if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } - 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()) - } - } -} -# 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. +# Fail open: with no readable helper, leave a no-op reader behind so the env +# files are skipped instead of the whole credential block dying on the load. +try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } +catch { function Read-RogueEnvFile { param([string]$Path) } } 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 trusted 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_]*)=(.*)$') { + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + } + } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 # before the API-key check below so the ordering cannot regress into reading it too diff --git a/plugins/copilot/scripts/heartbeat.sh b/plugins/copilot/scripts/heartbeat.sh index 88b46fe..f3ccfa4 100755 --- a/plugins/copilot/scripts/heartbeat.sh +++ b/plugins/copilot/scripts/heartbeat.sh @@ -34,10 +34,16 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + 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=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done +fi # 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..efdfa3f 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,22 +277,29 @@ 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'))) { - if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } - 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()) - } - } -} -# 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. +# Fail open: with no readable helper, leave a no-op reader behind so the env +# files are skipped instead of the whole credential block dying on the load. +try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } +catch { function Read-RogueEnvFile { param([string]$Path) } } 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 trusted 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_]*)=(.*)$') { + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + } + } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { continue } + foreach ($k in $fileVals.Keys) { $creds[$k] = $fileVals[$k] } + break +} # Logging is initialised HERE - after the credential files are parsed, so they can # relocate the log - but BEFORE the API-key check below, so an unconfigured diff --git a/plugins/copilot/scripts/hook.sh b/plugins/copilot/scripts/hook.sh index 5b995b6..b901f96 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,16 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + 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=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done +fi # 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..ba62e78 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', @@ -331,23 +332,30 @@ $SHIP_ENV_VARS = @( function Import-ShipEnv { $envLibrary = Join-Path $PluginRoot 'scripts/env-file.ps1' if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } $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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..1eb3152 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=[\"']?[^\"'[:space:]]" "$_env_file" 2>/dev/null; then + . "$_env_file" 2>/dev/null; break + fi done return 0 } diff --git a/plugins/copilot/skills/status/SKILL.md b/plugins/copilot/skills/status/SKILL.md index 95df291..c1c44c0 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,21 @@ 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: machine, bundled, user. +PLUGIN_ENV="$HOME/.copilot/installed-plugins/rogue-copilot/rogue/env" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$f" && { . "$f"; ROGUE_ENV_IN_USE=$f; break; } +done echo "Credential sources detected:" [ -r /etc/rogue/env ] && echo " /etc/rogue/env (MDM)" +[ -r "$PLUGIN_ENV" ] && echo " $PLUGIN_ENV (plugin bundle)" [ -r "$HOME/.rogue-env" ] && echo " $HOME/.rogue-env (per-user)" -[ ! -r /etc/rogue/env ] && [ ! -r "$HOME/.rogue-env" ] && echo " (none)" +[ ! -r /etc/rogue/env ] && [ ! -r "$PLUGIN_ENV" ] && [ ! -r "$HOME/.rogue-env" ] && echo " (none)" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$f" && echo " $f (no ROGUE_API_KEY, not read)" +done +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 +48,8 @@ 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" +PLUGIN_ENV="$HOME/.copilot/installed-plugins/rogue-copilot/rogue/env" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$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 +74,8 @@ 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" +PLUGIN_ENV="$HOME/.copilot/installed-plugins/rogue-copilot/rogue/env" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$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 +91,21 @@ 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="" +PLUGIN_ENV="$HOME/.copilot/installed-plugins/rogue-copilot/rogue/env" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$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 +122,27 @@ 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. -foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +# Mirror the dispatcher's rule: the first of C:\ProgramData\rogue\env (MDM), the +# plugin's bundled env 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. +$root = Join-Path $env:USERPROFILE '.copilot\installed-plugins\rogue-copilot\rogue' +foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $root '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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 +179,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..0b90125 100644 --- a/plugins/cursor/commands/status.md +++ b/plugins/cursor/commands/status.md @@ -5,13 +5,17 @@ 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), the plugin's bundled `env` (managed installs) 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: machine, bundled, user. +ROGUE_PLUGIN_ROOT="${CURSOR_PLUGIN_ROOT:-$HOME/.cursor/plugins/local/rogue}" +for f in /etc/rogue/env "$ROGUE_PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$f" && { . "$f"; echo " in use: $f"; break; } + [ -r "$f" ] && echo " $f (no ROGUE_API_KEY, not read)" +done [ -n "$ROGUE_API_KEY" ] && echo "API key resolved: ...${ROGUE_API_KEY: -4}" || { echo "API key: not resolved"; } ``` @@ -20,7 +24,8 @@ 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 +ROGUE_PLUGIN_ROOT="${CURSOR_PLUGIN_ROOT:-$HOME/.cursor/plugins/local/rogue}" +for f in /etc/rogue/env "$ROGUE_PLUGIN_ROOT/env" "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$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 +33,8 @@ 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 +ROGUE_PLUGIN_ROOT="${CURSOR_PLUGIN_ROOT:-$HOME/.cursor/plugins/local/rogue}" +for f in /etc/rogue/env "$ROGUE_PLUGIN_ROOT/env" "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$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 +44,26 @@ 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 +ROGUE_PLUGIN_ROOT="${CURSOR_PLUGIN_ROOT:-$HOME/.cursor/plugins/local/rogue}" +for f in /etc/rogue/env "$ROGUE_PLUGIN_ROOT/env" "$HOME/.rogue-env"; do [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$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="" +ROGUE_PLUGIN_ROOT="${CURSOR_PLUGIN_ROOT:-$HOME/.cursor/plugins/local/rogue}" +for f in /etc/rogue/env "$ROGUE_PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$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 +80,28 @@ 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. -foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $env:USERPROFILE '.rogue-env'))) { +# Mirror the dispatcher's rule: the first of C:\ProgramData\rogue\env (MDM), the +# plugin's bundled env 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. +$root = $env:CURSOR_PLUGIN_ROOT +if (-not $root) { $root = Join-Path $env:USERPROFILE '.cursor\plugins\local\rogue' } +foreach ($f in @('C:\ProgramData\rogue\env', (Join-Path $root '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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 +146,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/env-file.ps1 b/plugins/cursor/scripts/env-file.ps1 index f0155c9..c24662e 100644 --- a/plugins/cursor/scripts/env-file.ps1 +++ b/plugins/cursor/scripts/env-file.ps1 @@ -21,12 +21,14 @@ function Test-RogueEnvFile { $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $admins = @('S-1-5-18', 'S-1-5-32-544') $trusted = @($admins) + $user + # The machine file accepts only SYSTEM and Administrators as writers; the user file also accepts its owner. + $writers = if ($System) { $admins } else { $trusted } $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value if ($owner -notin $trusted -or ($System -and $owner -notin $admins)) { return $false } $write = [System.Security.AccessControl.FileSystemRights]'Write, Delete, ChangePermissions, TakeOwnership, DeleteSubdirectoriesAndFiles' foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $write) -and - $rule.IdentityReference.Value -notin $trusted) { return $false } + $rule.IdentityReference.Value -notin $writers) { return $false } } return $true } catch { return $false } diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index 0742223..40dc644 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,43 @@ 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" +# Fail open: with no readable helper, leave a no-op reader behind so the env +# files are skipped instead of the whole credential block dying on the load. +try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } +catch { function Read-RogueEnvFile { param([string]$Path) } } +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 trusted 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" - foreach ($line in (Get-Content -LiteralPath $f)) { - if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { - $k = $Matches[1] + $fileVals = @{} + foreach ($line in (Read-RogueEnvFile $f)) { + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$') { # 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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..6d48b6a 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,29 +76,24 @@ 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 -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" +# sourcing them is correct. The first trusted file holding ROGUE_API_KEY is used +# alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + for _f in /etc/rogue/env "$PLUGIN_ROOT/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_f" && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$_f"; then + dbg "cred file in use: $_f"; . "$_f" 2>/dev/null; break + else dbg "cred file skipped: $_f"; fi + done +fi # ── hook log ─────────────────────────────────────────────────────────────── # `dbg` above only writes to stderr under ROGUE_DEBUG, which Cursor keeps in its 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..ba62e78 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', @@ -331,23 +332,30 @@ $SHIP_ENV_VARS = @( function Import-ShipEnv { $envLibrary = Join-Path $PluginRoot 'scripts/env-file.ps1' if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } $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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..1eb3152 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=[\"']?[^\"'[:space:]]" "$_env_file" 2>/dev/null; then + . "$_env_file" 2>/dev/null; break + fi done return 0 } diff --git a/plugins/gemini/README.md b/plugins/gemini/README.md index ef2954f..35985ef 100644 --- a/plugins/gemini/README.md +++ b/plugins/gemini/README.md @@ -38,8 +38,9 @@ response from `AfterAgent`. missing key / network error / bad response returns `{}` (allow) and exits 0. - **Shared credentials.** Reads `~/.rogue-env` (mode 600) from disk each invocation — the SAME file the Claude Code, Codex, and Cursor Rogue plugins - use. Env precedence (later wins): `/env` → `/etc/rogue/env` - (`C:\ProgramData\rogue\env` on Windows) → `~/.rogue-env`. + use. One env file is read: the first of `/etc/rogue/env` + (`C:\ProgramData\rogue\env` on Windows), `/env`, and `~/.rogue-env` that + holds `ROGUE_API_KEY`. ## Install @@ -69,8 +70,7 @@ endpoint, your active rulesets, and a tail of recent hook activity (`~/.rogue/logs/gemini.log` — each Rogue plugin logs to its own file, capped at 10 MiB with one `.1` rotation kept). `ROGUE_LOG_MAX_BYTES` overrides that cap and `0` turns rotation off; `ROGUE_LOG_FILE` / `ROGUE_LOG_DIR` relocate the log. All -three are read from `~/.rogue-env` (or `/etc/rogue/env`), with the process -environment winning. +three are read from the env file in use, which overrides the process environment. ## Uninstall 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..9c95016 100644 --- a/plugins/gemini/scripts/shared.mjs +++ b/plugins/gemini/scripts/shared.mjs @@ -36,31 +36,53 @@ 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) +// Only root or the current user may supply configuration, and nobody else may +// write it; the machine file must be root's (env-file.sh's rule). Windows has no +// POSIX owner or mode, so the machine file is skipped there: the ACL check the +// PowerShell readers make has no Node equivalent. +export function isTrustedEnvFile(file) { + let st; + try { + st = fs.statSync(file); + } catch { + return false; + } + if (!st.isFile()) return false; + const system = file === "/etc/rogue/env" || file === "C:\\ProgramData\\rogue\\env"; + if (IS_WIN) return !system; + if (st.uid !== 0 && (system || st.uid !== process.getuid())) return false; + return (st.mode & 0o022) === 0; +} + +// Same env-file rule as the other monorepo plugins: the first trusted 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) { + if (!isTrustedEnvFile(f)) continue; let text; try { text = fs.readFileSync(f, "utf8"); } 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 (!String(vals.ROGUE_API_KEY || "").trim()) 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..a4ee5fb 100644 --- a/plugins/gemini/scripts/ship-logs.mjs +++ b/plugins/gemini/scripts/ship-logs.mjs @@ -29,7 +29,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { shellUnquote, IS_WIN } from "./shared.mjs"; +import { shellUnquote, isTrustedEnvFile, IS_WIN } from "./shared.mjs"; // ── constants ────────────────────────────────────────────────────────────── const SHIP_ENDPOINT_PATH = "/api/v1/hooks/logs"; @@ -50,32 +50,37 @@ 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) { - if (!envFile) continue; + if (!envFile || !isTrustedEnvFile(envFile)) continue; let text; try { text = fs.readFileSync(envFile, "utf8"); } 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 (!String(vals.ROGUE_API_KEY || "").trim()) 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..5768b15 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 @@ -18,13 +18,17 @@ Windows. There, the files are `C:\ProgramData\rogue\env` (MDM) and ## Step 1: Resolve credentials and report sources ```bash -resolve() { - for f in "$HOME/.gemini/extensions/rogue/env" /etc/rogue/env "$HOME/.rogue-env"; do - [ -r "$f" ] && . "$f" && echo " $f" >&2 - done -} -resolve 2>/tmp/rogue-src -echo "Credential sources detected:"; cat /tmp/rogue-src 2>/dev/null || echo " (none)" +ROGUE_ENV_IN_USE="" +# 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=[\"']?[^\"'[:space:]]" "$f" && { . "$f"; ROGUE_ENV_IN_USE=$f; break; } +done +echo "Credential sources detected:" +for f in /etc/rogue/env "$HOME/.gemini/extensions/rogue/env" "$HOME/.rogue-env"; do + [ -r "$f" ] || continue + if grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$f"; then echo " $f"; else echo " $f (no ROGUE_API_KEY, not read)"; fi +done +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" ``` @@ -39,7 +43,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=[\"']?[^\"'[:space:]]" "$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 +60,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=[\"']?[^\"'[:space:]]" "$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 +74,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=[\"']?[^\"'[:space:]]" "$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=[\"']?[^\"'[:space:]]" "$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 +188,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' + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$') { + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 +215,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/README.md b/plugins/kiro/README.md index d3978c7..61c6bc8 100644 --- a/plugins/kiro/README.md +++ b/plugins/kiro/README.md @@ -143,18 +143,13 @@ family `kiro` to when it decides whether a roster row is outdated. ## Credentials -Later file wins: +One env file is read: the first of these that holds `ROGUE_API_KEY`. Its values +override the process environment on both bridges. -1. `/env` — baked into a compiled customer plugin -2. `/etc/rogue/env` (`C:\ProgramData\rogue\env`) — MDM-provisioned +1. `/etc/rogue/env` (`C:\ProgramData\rogue\env`) — MDM-provisioned +2. `/env` — baked into a compiled customer plugin 3. `~/.rogue-env` — per-user, written by the installer -The two bridges differ on the process environment, as the sibling plugins do: -`hook.sh` sources the files (`export X=…`), so a value in a later file overwrites -one the hook inherited; `hook.ps1` reads the files into a map and then lets a -non-empty process-env value beat every file. Set the value in `~/.rogue-env` to -be sure it applies on both. - ## Tests ``` diff --git a/plugins/kiro/scripts/env-file.ps1 b/plugins/kiro/scripts/env-file.ps1 index f0155c9..c24662e 100644 --- a/plugins/kiro/scripts/env-file.ps1 +++ b/plugins/kiro/scripts/env-file.ps1 @@ -21,12 +21,14 @@ function Test-RogueEnvFile { $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $admins = @('S-1-5-18', 'S-1-5-32-544') $trusted = @($admins) + $user + # The machine file accepts only SYSTEM and Administrators as writers; the user file also accepts its owner. + $writers = if ($System) { $admins } else { $trusted } $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value if ($owner -notin $trusted -or ($System -and $owner -notin $admins)) { return $false } $write = [System.Security.AccessControl.FileSystemRights]'Write, Delete, ChangePermissions, TakeOwnership, DeleteSubdirectoriesAndFiles' foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $write) -and - $rule.IdentityReference.Value -notin $trusted) { return $false } + $rule.IdentityReference.Value -notin $writers) { return $false } } return $true } catch { return $false } diff --git a/plugins/kiro/scripts/heartbeat.ps1 b/plugins/kiro/scripts/heartbeat.ps1 index 542b3e0..a5d4cfd 100644 --- a/plugins/kiro/scripts/heartbeat.ps1 +++ b/plugins/kiro/scripts/heartbeat.ps1 @@ -110,20 +110,26 @@ function Get-BeaconLibrary { # ── credential resolution ────────────────────────────────────────────────── 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'))) { + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } + 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()) + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$') { + $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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..e6cabdf 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=[\"']?[^\"'[:space:]]" "$_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..e25972e 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,28 @@ 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'))) { + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $PluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } + 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()) + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$') { + $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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..c82e547 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=[\"']?[^\"'[:space:]]" "$_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..ba62e78 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', @@ -331,23 +332,30 @@ $SHIP_ENV_VARS = @( function Import-ShipEnv { $envLibrary = Join-Path $PluginRoot 'scripts/env-file.ps1' if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } $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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..1eb3152 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=[\"']?[^\"'[:space:]]" "$_env_file" 2>/dev/null; then + . "$_env_file" 2>/dev/null; break + fi done return 0 } diff --git a/plugins/kiro/scripts/status.sh b/plugins/kiro/scripts/status.sh index 62b84fc..7480646 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=[\"']?[^\"'[:space:]]" "$_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..8964eaf 100644 --- a/plugins/rogue/scripts/auto-update.ps1 +++ b/plugins/rogue/scripts/auto-update.ps1 @@ -79,24 +79,32 @@ 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 = @() - if ($env:CLAUDE_PLUGIN_ROOT) { $files += (Join-Path $env:CLAUDE_PLUGIN_ROOT 'env') } - $files += 'C:\ProgramData\rogue\env' + # Same rule as the dispatcher: the first trusted 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. + # Fail open: a no-op reader stands in until the helper loads, so a missing or + # unreadable one skips the env files instead of killing the whole lookup. + function Read-RogueEnvFile { param([string]$Path) } + $files = @('C:\ProgramData\rogue\env') + if ($env:CLAUDE_PLUGIN_ROOT) { + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts/env-file.ps1') -ErrorAction Stop))) } catch {} + $files += (Join-Path $env:CLAUDE_PLUGIN_ROOT 'env') + } $files += (Join-Path $env:USERPROFILE '.rogue-env') foreach ($f in $files) { if (-not (Test-Path -LiteralPath $f)) { continue } - foreach ($line in (Get-Content -LiteralPath $f)) { - if ($line -match ('^\s*(?:export\s+)?' + [regex]::Escape($Key) + '=(.+)$')) { - $v = $Matches[1].Trim().Trim("'").Trim('"') + $vals = @{} + foreach ($line in (Read-RogueEnvFile $f)) { + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$') { + $vals[$Matches[1]] = $Matches[2].Trim().Trim("'").Trim('"') } } + if (-not ([string]$vals['ROGUE_API_KEY']).Trim()) { 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..05942b4 100755 --- a/plugins/rogue/scripts/auto-update.sh +++ b/plugins/rogue/scripts/auto-update.sh @@ -27,13 +27,18 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + for _env_file in /etc/rogue/env "${CLAUDE_PLUGIN_ROOT:-}/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done +fi if [ "${ROGUE_AUTO_UPDATE:-1}" = "0" ]; then echo "ROGUE_AUTO_UPDATE=0, skipping" diff --git a/plugins/rogue/scripts/env-file.ps1 b/plugins/rogue/scripts/env-file.ps1 index f0155c9..c24662e 100644 --- a/plugins/rogue/scripts/env-file.ps1 +++ b/plugins/rogue/scripts/env-file.ps1 @@ -21,12 +21,14 @@ function Test-RogueEnvFile { $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $admins = @('S-1-5-18', 'S-1-5-32-544') $trusted = @($admins) + $user + # The machine file accepts only SYSTEM and Administrators as writers; the user file also accepts its owner. + $writers = if ($System) { $admins } else { $trusted } $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value if ($owner -notin $trusted -or ($System -and $owner -notin $admins)) { return $false } $write = [System.Security.AccessControl.FileSystemRights]'Write, Delete, ChangePermissions, TakeOwnership, DeleteSubdirectoriesAndFiles' foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $write) -and - $rule.IdentityReference.Value -notin $trusted) { return $false } + $rule.IdentityReference.Value -notin $writers) { return $false } } return $true } catch { return $false } diff --git a/plugins/rogue/scripts/heartbeat.ps1 b/plugins/rogue/scripts/heartbeat.ps1 index bbf0d46..07f1543 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,20 +135,27 @@ 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'))) { - if (-not $f -or -not (Test-Path -LiteralPath $f)) { continue } - 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()) - } - } -} -# 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. +# Fail open: with no readable helper, leave a no-op reader behind so the env +# files are skipped instead of the whole credential block dying on the load. +try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } +catch { function Read-RogueEnvFile { param([string]$Path) } } 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 trusted 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_]*)=(.*)$') { + $fileVals[$Matches[1]] = ConvertFrom-ShellQuoted ($Matches[2].Trim()) + } + } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 # API-key check below so the ordering cannot regress into reading it too early again. diff --git a/plugins/rogue/scripts/heartbeat.sh b/plugins/rogue/scripts/heartbeat.sh index 9f0c47c..78bdd36 100755 --- a/plugins/rogue/scripts/heartbeat.sh +++ b/plugins/rogue/scripts/heartbeat.sh @@ -31,10 +31,16 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + for _env_file in /etc/rogue/env "${CLAUDE_PLUGIN_ROOT:-}/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done +fi # 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..4dbf7f4 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,38 @@ try { } catch { $script:surface = '' } Dbg "surface=$($script:surface)" -# -- credential resolution (later file wins; process env wins over all) ----- +# -- credential resolution --------------------------------------------------- $creds = @{} +# Fail open: with no readable helper, leave a no-op reader behind so the env +# files are skipped instead of the whole credential block dying on the load. +try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath (Join-Path $pluginRoot 'scripts/env-file.ps1') -ErrorAction Stop))) } +catch { function Read-RogueEnvFile { param([string]$Path) } } +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 trusted 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" - 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 + $fileVals = @{} + foreach ($line in (Read-RogueEnvFile $f)) { + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$') { + # 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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..99c96cc 100644 --- a/plugins/rogue/scripts/hook.sh +++ b/plugins/rogue/scripts/hook.sh @@ -12,9 +12,16 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + for _env_file in /etc/rogue/env "${CLAUDE_PLUGIN_ROOT:-}/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done +fi [ -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..ba62e78 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', @@ -331,23 +332,30 @@ $SHIP_ENV_VARS = @( function Import-ShipEnv { $envLibrary = Join-Path $PluginRoot 'scripts/env-file.ps1' if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } $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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..1eb3152 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=[\"']?[^\"'[:space:]]" "$_env_file" 2>/dev/null; then + . "$_env_file" 2>/dev/null; break + fi done return 0 } diff --git a/plugins/rogue/scripts/statusline.sh b/plugins/rogue/scripts/statusline.sh index 2d47652..7234391 100755 --- a/plugins/rogue/scripts/statusline.sh +++ b/plugins/rogue/scripts/statusline.sh @@ -9,8 +9,9 @@ # the path is stable across plugin-cache upgrades). Keep the two in sync. set -u +# Presence only: the badge never executes an env file. for f in /etc/rogue/env "$HOME/.rogue-env"; do - [ -r "$f" ] && . "$f" + if [ -r "$f" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$f"; then ROGUE_API_KEY=found; 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..6729555 100644 --- a/plugins/rogue/scripts/warn.sh +++ b/plugins/rogue/scripts/warn.sh @@ -7,9 +7,16 @@ 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 trusted env file holding ROGUE_API_KEY is used alone: machine, bundled, user. +_env_lib="$(dirname -- "$0")/env-file.sh" +if [ -r "$_env_lib" ]; then + . "$_env_lib" + for _env_file in /etc/rogue/env "${CLAUDE_PLUGIN_ROOT:-}/env" "$HOME/.rogue-env"; do + if rogue_env_is_trusted "$_env_file" && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$_env_file"; then + . "$_env_file"; break + fi + done +fi [ -z "${CLAUDE_CODE_ENTRYPOINT:-}" ] && exit 0 diff --git a/plugins/rogue/skills/status/SKILL.md b/plugins/rogue/skills/status/SKILL.md index 0b75b12..94880f2 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=[\"']?[^\"'[:space:]]" "$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,10 @@ 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)" +for f in /etc/rogue/env "$PLUGIN_ENV" "$HOME/.rogue-env"; do + [ -n "$f" ] && [ -r "$f" ] && ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$f" && echo " $f (no ROGUE_API_KEY, not read)" +done +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 +120,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 +169,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=[\"']?[^\"'[:space:]]" "$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 +298,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 +328,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 +386,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' + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$') { + $fileVals[$Matches[1]] = $Matches[2].Trim() -replace "^'(.*)'$",'$1' -replace '^"(.*)"$','$1' } } + if (-not ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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 +471,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/mdm-provision-actor.sh b/scripts/mdm-provision-actor.sh index 05c0c50..c215715 100755 --- a/scripts/mdm-provision-actor.sh +++ b/scripts/mdm-provision-actor.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Provision /etc/rogue/env with end-user identity for MDM-managed machines. +# Provision /etc/rogue/env (API key + end-user identity) on MDM-managed machines. # # Why: the compiled plugin's bundled env can't reliably derive end-user # identity on managed/ephemeral machines (e.g. Cowork VMs) where hostname is @@ -7,15 +7,16 @@ # ONE, etc.) knows the assigned user — push that knowledge into # /etc/rogue/env so the hook layer picks it up at runtime. # -# Plugin hook source order (later wins): -# ${CLAUDE_PLUGIN_ROOT}/env — bundled defaults (API key, mode) -# /etc/rogue/env — this file, MDM-deployed identity -# ~/.rogue-env — per-user override +# The hooks read ONE env file: the first of /etc/rogue/env, /env, +# ~/.rogue-env that holds ROGUE_API_KEY, and nothing from the others. So this +# file must carry the key to be read at all, and once it does it replaces the +# bundled env entirely — mode and the auto-update pin included. # # Usage — env vars (recommended for MDM payloads that substitute their own # placeholders for the assigned user): # # # Kandji Custom Script body example: +# ROGUE_API_KEY="rsk_..." \ # ROGUE_ACTOR_EMAIL="$USER_EMAIL" \ # ROGUE_ACTOR_NAME="$USER_FULL_NAME" \ # bash mdm-provision-actor.sh @@ -23,15 +24,17 @@ # Usage — CLI args (handy for manual testing): # # sudo bash mdm-provision-actor.sh \ +# --key rsk_... \ # --email alice@example.com \ # --name "Alice Smith" # -# Optional flags / env vars (all override values previously written): -# --key / ROGUE_API_KEY override the bundled API key -# --mode / ROGUE_PRETOOLUSE_ON_BLOCK "ask" (default) or "block" -# --base-url / ROGUE_BASE_URL custom Rogue endpoint +# Required: --key / ROGUE_API_KEY, --email / ROGUE_ACTOR_EMAIL, --name / ROGUE_ACTOR_NAME. +# Optional flags / env vars: +# --mode / ROGUE_PRETOOLUSE_ON_BLOCK "ask" or "block" (hook default when unset) +# --base-url / ROGUE_BASE_URL custom Rogue endpoint +# --auto-update / ROGUE_AUTO_UPDATE 0 (default; matches compiled bundles) or 1 # -# Required: actor email + name. Must run as root (writes to /etc/rogue/env). +# Must run as root (writes to /etc/rogue/env). set -euo pipefail @@ -40,6 +43,7 @@ NAME="${ROGUE_ACTOR_NAME:-}" KEY="${ROGUE_API_KEY:-}" MODE="${ROGUE_PRETOOLUSE_ON_BLOCK:-}" BASE_URL="${ROGUE_BASE_URL:-}" +AUTO_UPDATE="${ROGUE_AUTO_UPDATE:-0}" while [ $# -gt 0 ]; do case "$1" in @@ -48,14 +52,16 @@ while [ $# -gt 0 ]; do --key) KEY="$2"; shift 2 ;; --mode) MODE="$2"; shift 2 ;; --base-url) BASE_URL="$2"; shift 2 ;; + --auto-update) AUTO_UPDATE="$2"; shift 2 ;; -h|--help) - sed -n '2,32p' "$0" 2>/dev/null + sed -n '2,37p' "$0" 2>/dev/null exit 0 ;; *) echo "Unknown arg: $1" >&2; exit 2 ;; esac done +[ -n "$KEY" ] || { echo "ROGUE_API_KEY (or --key) required: the hooks skip an env file without it" >&2; exit 2; } [ -n "$EMAIL" ] || { echo "ROGUE_ACTOR_EMAIL (or --email) required" >&2; exit 2; } [ -n "$NAME" ] || { echo "ROGUE_ACTOR_NAME (or --name) required" >&2; exit 2; } @@ -63,6 +69,10 @@ case "$MODE" in ""|ask|block) ;; *) echo "Bad --mode: $MODE (expected: ask|block)" >&2; exit 2 ;; esac +case "$AUTO_UPDATE" in + 0|1) ;; + *) echo "Bad --auto-update: $AUTO_UPDATE (expected: 0|1)" >&2; exit 2 ;; +esac [ "$(id -u)" -eq 0 ] || { echo "must run as root (writes /etc/rogue/env)" >&2 @@ -75,16 +85,16 @@ trap 'rm -f "$TMP"' EXIT { echo "# Provisioned by mdm-provision-actor.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'export ROGUE_API_KEY=%q\n' "$KEY" printf 'export ROGUE_ACTOR_EMAIL=%q\n' "$EMAIL" printf 'export ROGUE_ACTOR_NAME=%q\n' "$NAME" - [ -n "$KEY" ] && printf 'export ROGUE_API_KEY=%q\n' "$KEY" [ -n "$MODE" ] && printf 'export ROGUE_PRETOOLUSE_ON_BLOCK=%q\n' "$MODE" [ -n "$BASE_URL" ] && printf 'export ROGUE_BASE_URL=%q\n' "$BASE_URL" + printf 'export ROGUE_AUTO_UPDATE=%s\n' "$AUTO_UPDATE" } > "$TMP" -# /etc convention: root-owned, world-readable so per-user hooks can source it. -# If the API key is included, treat the file as sensitive — tighten to 0640 -# under a group the user belongs to if needed in your environment. +# Root-owned, world-readable: the hooks run as each user and must read it. To +# narrow readers, use 0640 under a group the human users belong to. chmod 0644 "$TMP" chown root:wheel "$TMP" 2>/dev/null || chown root:root "$TMP" 2>/dev/null || true mv -f "$TMP" /etc/rogue/env diff --git a/scripts/shared/env-file.ps1 b/scripts/shared/env-file.ps1 index f0155c9..c24662e 100644 --- a/scripts/shared/env-file.ps1 +++ b/scripts/shared/env-file.ps1 @@ -21,12 +21,14 @@ function Test-RogueEnvFile { $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $admins = @('S-1-5-18', 'S-1-5-32-544') $trusted = @($admins) + $user + # The machine file accepts only SYSTEM and Administrators as writers; the user file also accepts its owner. + $writers = if ($System) { $admins } else { $trusted } $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value if ($owner -notin $trusted -or ($System -and $owner -notin $admins)) { return $false } $write = [System.Security.AccessControl.FileSystemRights]'Write, Delete, ChangePermissions, TakeOwnership, DeleteSubdirectoriesAndFiles' foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $write) -and - $rule.IdentityReference.Value -notin $trusted) { return $false } + $rule.IdentityReference.Value -notin $writers) { return $false } } return $true } catch { return $false } diff --git a/scripts/shared/ship-logs.ps1 b/scripts/shared/ship-logs.ps1 index b375667..ba62e78 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', @@ -331,23 +332,30 @@ $SHIP_ENV_VARS = @( function Import-ShipEnv { $envLibrary = Join-Path $PluginRoot 'scripts/env-file.ps1' if ($PSCommandPath) { $envLibrary = Join-Path (Split-Path -Parent $PSCommandPath) 'env-file.ps1' } - . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary))) + # Fail open: with no readable helper, leave a no-op reader behind so the env + # files are skipped instead of the whole credential block dying on the load. + try { . ([scriptblock]::Create((Get-Content -Raw -LiteralPath $envLibrary -ErrorAction Stop))) } + catch { function Read-RogueEnvFile { param([string]$Path) } } $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 ([string]$fileVals['ROGUE_API_KEY']).Trim()) { 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..1eb3152 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=[\"']?[^\"'[:space:]]" "$_env_file" 2>/dev/null; then + . "$_env_file" 2>/dev/null; break + fi done return 0 } 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_file_trust.ps1 b/tests/test_env_file_trust.ps1 index 8fec370..0118bd0 100644 --- a/tests/test_env_file_trust.ps1 +++ b/tests/test_env_file_trust.ps1 @@ -21,6 +21,23 @@ try { } if (Test-RogueEnvFile $file) { throw 'world-writable env was trusted' } if (@(Read-RogueEnvFile $file).Count -ne 0) { throw 'unsafe env was read' } + # A write grant to the current user is fine for the user file. The machine file + # is rejected here on OWNERSHIP (a user-owned file is never a machine file); the + # admins-only writer rule that ownership check sits in front of needs an + # Administrators-owned fixture, which an unelevated test cannot create. + # Recreate the file: the Everyone rule above is explicit, so protection alone would keep it. + Remove-Item -LiteralPath $file -Force + [System.IO.File]::WriteAllText($file, 'ROGUE_TEST_VALUE=trusted') + if ($unix) { & chmod 600 $file } + else { + if (-not (Protect-RogueEnvFile $file)) { throw $script:RogueEnvProtectError } + $acl = Get-Acl -LiteralPath $file + $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($me, 'Write', 'Allow'))) + Set-Acl -LiteralPath $file -AclObject $acl + } + if (-not (Test-RogueEnvFile $file)) { throw 'user-writable user env was rejected' } + if (Test-RogueEnvFile $file -System) { throw 'user-owned machine env was trusted' } if (Test-RogueEnvFile (Join-Path $dir 'missing')) { throw 'missing env was trusted' } Write-Host 'env-file trust: all checks passed' } finally { Remove-Item -Recurse -Force $dir } diff --git a/tests/test_env_first_found.mjs b/tests/test_env_first_found.mjs new file mode 100644 index 0000000..2fb0856 --- /dev/null +++ b/tests/test_env_first_found.mjs @@ -0,0 +1,258 @@ +// 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 others can write is skipped, key or no key", { skip: process.platform === "win32" }, async () => { + const sb = sandbox(); + try { + write(sb.machine, ["export ROGUE_API_KEY=machine-key", "export ROGUE_BASE_URL=http://machine.invalid"]); + fs.chmodSync(sb.machine, 0o666); + 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: 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: an empty ROGUE_API_KEY, quoted or bare, does not select the file", async () => { + const sb = sandbox(); + try { + write(sb.machine, ["export ROGUE_API_KEY=''", "export ROGUE_BASE_URL=http://machine.invalid"]); + write(sb.bundled, ["ROGUE_API_KEY=", "export ROGUE_BASE_URL=http://bundled.invalid"]); + write(sb.user, ["export ROGUE_API_KEY=user-key", "export ROGUE_BASE_URL=http://user.invalid"]); + const env = await resolve(sb, {}); + assert.equal(env.ROGUE_API_KEY, "user-key"); + assert.equal(env.ROGUE_BASE_URL, "http://user.invalid"); + } finally { + sb.cleanup(); + } +}); + +test("loadEnvFiles: a quoted whitespace-only ROGUE_API_KEY does not select the file", async () => { + const sb = sandbox(); + try { + write(sb.machine, ['export ROGUE_API_KEY=" "', "export ROGUE_BASE_URL=http://machine.invalid"]); + write(sb.user, ["export ROGUE_API_KEY=user-key", "export ROGUE_BASE_URL=http://user.invalid"]); + const env = await resolve(sb, {}); + assert.equal(env.ROGUE_API_KEY, "user-key"); + assert.equal(env.ROGUE_BASE_URL, "http://user.invalid"); + } finally { + sb.cleanup(); + } +}); + +// Structural, because the sandbox rewrites the machine literal and a POSIX runner +// cannot exercise the Windows branch: isTrustedEnvFile must recognise BOTH machine +// paths as system, or `if (IS_WIN) return !system` trusts C:\ProgramData\rogue\env +// unchecked - the one candidate Node cannot verify, since it cannot read an ACL. +test("isTrustedEnvFile: the Windows machine path counts as a system candidate", () => { + const source = fs.readFileSync(path.join(SCRIPTS, "shared.mjs"), "utf8"); + const predicate = source.match(/const system = .*/)[0]; + assert.match(predicate, /"\/etc\/rogue\/env"/); + assert.match(predicate, /"C:\\\\ProgramData\\\\rogue\\\\env"/); +}); + +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 or world-writable one", async () => { + const cases = [ + { machineLines: ["export ROGUE_API_KEY=machine-key"], expected: "machine-key" }, + { machineLines: ["export ROGUE_BASE_URL=http://machine.invalid"], expected: "bundled-key" }, + ]; + if (process.platform !== "win32") { + cases.push({ machineLines: ["export ROGUE_API_KEY=machine-key"], mode: 0o666, expected: "bundled-key" }); + } + for (const { machineLines, mode, expected } of cases) { + 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); + if (mode) fs.chmodSync(sb.machine, mode); + 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..87f34c5 --- /dev/null +++ b/tests/test_env_first_found.ps1 @@ -0,0 +1,262 @@ +#!/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. +# +# Every reader runs its REAL credential code, 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. Loaders reachable +# through the ROGUE_PS_LIB_ONLY seam (the shared shipper's Import-ShipEnv, the +# antigravity/kiro Import-Credentials, auto-update's ReadEnvVar) are called as +# functions; the dispatchers whose credential block runs at file scope, past the +# seam, have that block lifted from the source (from `$creds = @{}` through the +# `break }` that ends the file loop) and executed in place. +# +# 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 } } +# Lets identities other than the owner write the file, as tests/test_env_file_trust.ps1 does. +function Set-WorldWritable { param([string]$Path) + if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { & chmod 666 $Path; return } + $acl = Get-Acl -LiteralPath $Path + $everyone = New-Object System.Security.Principal.SecurityIdentifier('S-1-1-0') + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($everyone, 'Write', 'Allow'))) + Set-Acl -LiteralPath $Path -AclObject $acl +} + +$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', 'CURSOR_PLUGIN_ROOT', + 'PLUGIN_ROOT', 'COPILOT_PLUGIN_ROOT', 'KIRO_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]) } +} + +# The reader's source with the machine path pointed into the sandbox. +function Get-RedirectedSource { param([string]$Rel) + $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" } + return $text.Replace($literal, "'" + $machine + "'") +} + +# Dot-sources one reader, then runs its loader function 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-RedirectedSource $Rel + $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 +} + +# One named function's source: the single-line form first, else through the first +# column-0 close brace. Never the whole file - the heartbeats have no seam and would +# `exit 0` the test process off-Windows. +function Get-FunctionSource { param([string]$Text, [string]$Name, [string]$Rel) + $m = [regex]::Match($Text, "\nfunction $Name \{[^\n]*\}(?=\r?\n)") + if (-not $m.Success) { $m = [regex]::Match($Text, "(?s)\nfunction $Name \{.*?\n\}(?=\r?\n)") } + if (-not $m.Success) { throw "${Rel}: function $Name not found" } + return $m.Value +} + +# Lifts a dispatcher's file-scope credential block (and the two helpers it calls) +# out of the source and runs it here. +function Resolve-FileScope { param([string]$Rel) + $text = Get-RedirectedSource $Rel + $block = [regex]::Match($text, '(?s)\n[ \t]*\$(?:script:)?creds = @\{\}\r?\n.*?\n[ \t]*break\r?\n[ \t]*\}\r?\n') + if (-not $block.Success) { throw "${Rel}: no file-scope credential block found" } + foreach ($name in 'Dbg', 'ConvertFrom-ShellQuoted') { + . ([scriptblock]::Create((Get-FunctionSource $text $name $Rel))) + } + $script:creds = @{} + $PluginRoot = $root + $script:PluginRoot = $root + . ([scriptblock]::Create($block.Value)) + return $creds +} + +# auto-update.ps1 exposes one getter rather than a map; read the two keys through it. +function Resolve-AutoUpdate { + $text = Get-RedirectedSource 'plugins/rogue/scripts/auto-update.ps1' + $fn = [regex]::Match($text, '(?s)\nfunction ReadEnvVar \{.*?\n\}\r?\n') + if (-not $fn.Success) { throw 'auto-update.ps1: ReadEnvVar not found' } + . ([scriptblock]::Create($fn.Value)) + if (-not (Get-Command ReadEnvVar -ErrorAction SilentlyContinue)) { throw 'auto-update.ps1: ReadEnvVar did not load' } + return @{ ROGUE_API_KEY = (ReadEnvVar 'ROGUE_API_KEY'); ROGUE_BASE_URL = (ReadEnvVar 'ROGUE_BASE_URL') } +} + +$readers = @( + @{ label = 'ship-logs.ps1 Import-ShipEnv'; resolve = { Resolve-With 'scripts/shared/ship-logs.ps1' 'Import-ShipEnv' } }, + @{ label = 'antigravity hook.ps1'; resolve = { Resolve-With 'plugins/antigravity/scripts/hook.ps1' 'Import-Credentials' } }, + @{ label = 'antigravity heartbeat.ps1'; resolve = { Resolve-With 'plugins/antigravity/scripts/heartbeat.ps1' 'Import-Credentials' } }, + @{ label = 'kiro heartbeat.ps1'; resolve = { Resolve-With 'plugins/kiro/scripts/heartbeat.ps1' 'Import-Credentials' } }, + @{ label = 'rogue hook.ps1'; resolve = { Resolve-FileScope 'plugins/rogue/scripts/hook.ps1' } }, + @{ label = 'codex hook.ps1'; resolve = { Resolve-FileScope 'plugins/codex/scripts/hook.ps1' } }, + @{ label = 'copilot hook.ps1'; resolve = { Resolve-FileScope 'plugins/copilot/scripts/hook.ps1' } }, + @{ label = 'cursor hook.ps1'; resolve = { Resolve-FileScope 'plugins/cursor/scripts/hook.ps1' } }, + @{ label = 'kiro hook.ps1'; resolve = { Resolve-FileScope 'plugins/kiro/scripts/hook.ps1' } }, + @{ label = 'rogue heartbeat.ps1'; resolve = { Resolve-FileScope 'plugins/rogue/scripts/heartbeat.ps1' } }, + @{ label = 'codex heartbeat.ps1'; resolve = { Resolve-FileScope 'plugins/codex/scripts/heartbeat.ps1' } }, + @{ label = 'copilot heartbeat.ps1'; resolve = { Resolve-FileScope 'plugins/copilot/scripts/heartbeat.ps1' } }, + @{ label = 'rogue auto-update.ps1 ReadEnvVar'; resolve = { Resolve-AutoUpdate } }) + +try { + $env:USERPROFILE = $sbHome + $env:HOME = $sbHome + $env:ROGUE_PS_LIB_ONLY = '1' + # Each dispatcher names its own plugin-root variable; the sandbox root is all of them. + foreach ($k in 'CLAUDE_PLUGIN_ROOT', 'CURSOR_PLUGIN_ROOT', 'PLUGIN_ROOT', 'COPILOT_PLUGIN_ROOT', 'KIRO_PLUGIN_ROOT') { + [Environment]::SetEnvironmentVariable($k, $root) + } + + foreach ($r in $readers) { + $L = $r.label + Write-Host "== $L" + + # 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 = & $r.resolve + Check "${L}: machine file wins with all three present" 'machine-key' $m['ROGUE_API_KEY'] + Check "${L}: nothing merged from the user file" 'http://machine.invalid' $m['ROGUE_BASE_URL'] + + # A machine file others can write is not a candidate, key or no key. + Clear-EnvFiles + Set-EnvFile $machine @('export ROGUE_API_KEY=machine-key', 'export ROGUE_BASE_URL=http://machine.invalid') + Set-WorldWritable $machine + 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 @{} + $m = & $r.resolve + Check "${L}: a world-writable machine file is skipped" 'bundled-key' $m['ROGUE_API_KEY'] + Check "${L}: ...and contributes nothing" 'http://bundled.invalid' $m['ROGUE_BASE_URL'] + + # A machine file without ROGUE_API_KEY is skipped whole; the bundled file is next. + Clear-EnvFiles + Set-EnvFile $machine @('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') + $m = & $r.resolve + Check "${L}: keyless machine file is skipped" 'bundled-key' $m['ROGUE_API_KEY'] + Check "${L}: ...and contributes nothing" 'http://bundled.invalid' $m['ROGUE_BASE_URL'] + + # An empty ROGUE_API_KEY, quoted or bare, does not select the file - the same + # answer the sh gate and the mjs readers give. + Set-EnvFile $machine @("export ROGUE_API_KEY=''", 'export ROGUE_BASE_URL=http://machine.invalid') + Set-EnvFile $bundled @('ROGUE_API_KEY=', 'export ROGUE_BASE_URL=http://bundled.invalid') + $m = & $r.resolve + Check "${L}: an empty key line does not select the file" 'user-key' $m['ROGUE_API_KEY'] + Check "${L}: ...and contributes nothing either" 'http://user.invalid' $m['ROGUE_BASE_URL'] + + # A quoted whitespace-only key is not a key: the sh predicate requires a + # non-whitespace character after the optional quote, so the readers must agree. + Clear-EnvFiles + Set-EnvFile $machine @('export ROGUE_API_KEY=" "', 'export ROGUE_BASE_URL=http://machine.invalid') + Set-EnvFile $user @('export ROGUE_API_KEY=user-key', 'export ROGUE_BASE_URL=http://user.invalid') + Set-ProcessEnv @{} + $m = & $r.resolve + Check "${L}: a whitespace-only key does not select the file" 'user-key' $m['ROGUE_API_KEY'] + Check "${L}: ...and contributes nothing at all" 'http://user.invalid' $m['ROGUE_BASE_URL'] + + # An empty assignment in the chosen file is a VALUE, and clears the process + # one - exactly what sourcing the file does in the sh readers. + Clear-EnvFiles + Set-EnvFile $user @('export ROGUE_API_KEY=user-key', 'export ROGUE_BASE_URL=') + Set-ProcessEnv @{ ROGUE_BASE_URL = 'http://process.invalid' } + $m = & $r.resolve + Check "${L}: an empty assignment clears the process value" '' $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 = & $r.resolve + Check "${L}: the chosen file overrides the process env" 'user-key' $m['ROGUE_API_KEY'] + Check "${L}: 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 = & $r.resolve + Check "${L}: process env alone still configures" 'process-key' $m['ROGUE_API_KEY'] + } + + # codex warn.ps1 exits before its loop off-Windows and has no seam; hold its + # source to the rule instead. + Write-Host '== structural: codex warn.ps1' + $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: reads through the trust gate' $true ($warn -match 'foreach \(\$line in \(Read-RogueEnvFile \$f\)\)') + 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'\) \}") + + # Every reader above is exercised with its plugin-root variable already set, so + # a dot-source placed BEFORE that variable is assigned passes here and loads + # nothing in production - Read-RogueEnvFile is then undefined and the whole + # credential block dies silently. Hold the shipped source to the order. + Write-Host '== structural: env-file.ps1 is dot-sourced after the plugin root is known' + foreach ($file in (Get-ChildItem -Path (Join-Path $repo 'plugins') -Recurse -Filter *.ps1 -File)) { + $text = Get-Content -Raw -LiteralPath $file.FullName + $load = [regex]::Match($text, "Join-Path \`$([\w:]+) 'scripts/env-file\.ps1'") + if (-not $load.Success) { continue } + $var = $load.Groups[1].Value + # $env:... is read straight from the environment, so there is nothing to assign. + if ($var -like 'env:*') { continue } + # Matches both a plain assignment and the `param([string]$PluginRoot = '')` form. + $assign = [regex]::Match($text, "\`$(?:script:)?$var\s*=", 'IgnoreCase') + Check "$($file.Name): plugin root assigned before env-file.ps1 is loaded" $true ` + ($assign.Success -and $assign.Index -lt $load.Index) + } +} finally { + foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } + Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host '' +# A dispatcher that reached an `exit` while being loaded would end this process +# early with a clean status; the count proves every reader ran every scenario. +if ($script:count -lt ($readers.Count * 14)) { Write-Host "only $script:count checks ran"; exit 1 } +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..7a758da --- /dev/null +++ b/tests/test_env_first_found.sh @@ -0,0 +1,183 @@ +#!/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() { #