diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e16fd73..051a964 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -118,6 +118,7 @@ jobs: 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 + bash tests/test_install_env_sh.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 @@ -362,6 +363,7 @@ jobs: 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_install_env_ps1.ps1 pwsh -NoProfile -File tests/test_status_kiro_ps1.ps1 windows: @@ -427,5 +429,7 @@ jobs: 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_install_env_ps1.ps1 + if ($LASTEXITCODE -ne 0) { exit 1 } powershell -NoProfile -File tests/test_status_kiro_ps1.ps1 if ($LASTEXITCODE -ne 0) { exit 1 } diff --git a/README.md b/README.md index 10cadc6..217a35d 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,14 @@ The one installer detects every supported coding agent and installs the matching Rogue plugin into each — **Claude Code**, **OpenAI Codex**, **Cursor**, **Gemini CLI**, **GitHub Copilot CLI**, **Google Antigravity**, and **Kiro** — writing the shared `~/.rogue-env` (`%USERPROFILE%\.rogue-env` on -Windows) once. Claude and Codex install through their native plugin CLIs -(`claude plugin install` / `codex plugin add`); **Cursor has no plugin CLI**, so +Windows) once. On a machine whose `/etc/rogue/env` (`C:\ProgramData\rogue\env`) +already holds `ROGUE_API_KEY` it prompts for nothing and writes no user file: +that machine file is the one the hooks read, and its key is validated in place. +A machine file that is not root-owned (SYSTEM/Administrators on Windows) or is +writable by others is skipped by Kiro and the log shipper, so the installer +warns and falls back to the user file. Claude and Codex install through +their native plugin CLIs (`claude plugin install` / `codex plugin add`); +**Cursor has no plugin CLI**, so its plugin is copied into `~/.cursor/plugins/local/rogue` from the release tarball; **Gemini CLI** installs from the release tarball via its native `gemini extensions install`. **Kiro** (IDE, CLI on both engines, Crew) has no diff --git a/install.ps1 b/install.ps1 index 2607bb6..3ec1644 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,6 +7,8 @@ With credentials via environment variables (non-interactive): $env:ROGUE_API_KEY='rsk_xxx'; $env:ROGUE_ACTOR_EMAIL='you@co.com'; iwr -useb https://raw.githubusercontent.com/qualifire-dev/rogue-plugins/main/install.ps1 | iex + A key passed this way is ignored when C:\ProgramData\rogue\env already holds + one: that file is read alone. Direct invocation with flags: .\install.ps1 -ApiKey rsk_xxx -Email you@co.com -Name 'Your Name' @@ -70,6 +72,7 @@ $MarketplaceName = 'rogue-marketplace' $CopilotMarketplaceName = 'rogue-copilot' $PluginName = 'rogue' $EnvFile = Join-Path $env:USERPROFILE '.rogue-env' +$MachineEnvFile = 'C:\ProgramData\rogue\env' # Merge env vars -> params (explicit params win). if (-not $ApiKey) { $ApiKey = $env:ROGUE_API_KEY } @@ -320,67 +323,7 @@ function Test-KiroInstalled { return [bool](Test-Path (Join-Path $env:USERPROFILE '.kiro')) } -# Test seam: load only the functions above (tests/test_install_kiro_ps1.ps1). -if ($env:ROGUE_INSTALL_LIB_ONLY) { return } - -try { - [Net.ServicePointManager]::SecurityProtocol = ` - [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 -} catch {} - -Write-Host "" -Write-Host "Rogue Security (Windows)" -ForegroundColor Cyan - -# Agent selection. -Claude/-Codex/-Cursor pick an explicit set; with none, auto-detect -# every supported agent. claude/codex ship a CLI on PATH; Cursor's `cursor` command is -# opt-in, so detection also accepts %USERPROFILE%\.cursor. An explicitly selected CLI -# agent still needs its binary; Cursor is a plain file copy, so it installs regardless. -# Antigravity has no `antigravity` binary on PATH — detect the `agy` CLI or its data -# dirs under %USERPROFILE%\.gemini (IDE and/or manual-CLI installs). -$explicit = $Claude -or $Codex -or $Cursor -or $Gemini -or $Copilot -or $Antigravity -or $Kiro -if ($explicit) { - $hasClaude = [bool]$Claude - $hasCodex = [bool]$Codex - $hasCursor = [bool]$Cursor - $hasGemini = [bool]$Gemini - $hasCopilot = [bool]$Copilot - $hasAntigravity = [bool]$Antigravity - $hasKiro = [bool]$Kiro - if ($hasClaude -and -not (Get-Command claude -ErrorAction SilentlyContinue)) { - Die "-Claude requested but the 'claude' CLI is not on PATH. Install Claude Code (https://claude.com/code) first." - } - if ($hasCodex -and -not (Get-Command codex -ErrorAction SilentlyContinue)) { - Die "-Codex requested but the 'codex' CLI is not on PATH. Install OpenAI Codex first." - } - if ($hasGemini -and -not (Get-Command gemini -ErrorAction SilentlyContinue)) { - Die "-Gemini requested but the 'gemini' CLI is not on PATH. Install Gemini CLI (https://geminicli.com) first." - } - if ($hasCopilot -and -not (Get-Command copilot -ErrorAction SilentlyContinue)) { - Die "-Copilot requested but the 'copilot' CLI is not on PATH. Install GitHub Copilot CLI (https://github.com/github/copilot-cli) first." - } - if ($hasAntigravity -and -not ((Get-Command agy -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:USERPROFILE '.gemini\antigravity*')))) { - Die "-Antigravity requested but no Antigravity install was detected (looked for: agy CLI, %USERPROFILE%\.gemini\antigravity*). Install Google Antigravity first." - } - if ($hasKiro -and -not (Test-KiroInstalled)) { - Die "-Kiro requested but no Kiro install was detected (looked for: kiro-cli, %LOCALAPPDATA%\Programs\Kiro, %USERPROFILE%\.kiro). Install Kiro (https://kiro.dev) first." - } -} else { - $hasClaude = [bool](Get-Command claude -ErrorAction SilentlyContinue) - $hasCodex = [bool](Get-Command codex -ErrorAction SilentlyContinue) - $hasCursor = [bool](Get-Command cursor -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:USERPROFILE '.cursor')) - $hasGemini = [bool](Get-Command gemini -ErrorAction SilentlyContinue) - $hasCopilot = [bool](Get-Command copilot -ErrorAction SilentlyContinue) - $hasAntigravity = [bool](Get-Command agy -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:USERPROFILE '.gemini\antigravity*')) - $hasKiro = Test-KiroInstalled - if (-not ($hasClaude -or $hasCodex -or $hasCursor -or $hasGemini -or $hasCopilot -or $hasAntigravity -or $hasKiro)) { - Die "No supported coding agent found (looked for: claude, codex, cursor, gemini, copilot, antigravity, kiro). Install Claude Code (https://claude.com/code), OpenAI Codex, Cursor (https://cursor.com), Gemini CLI (https://geminicli.com), GitHub Copilot CLI (https://github.com/github/copilot-cli), Google Antigravity, or Kiro (https://kiro.dev) first." - } -} -# Claude shells out to git to clone the marketplace; git is required only for it. -if ($hasClaude -and -not (Get-Command git -ErrorAction SilentlyContinue)) { - Die "git not found. Install Git for Windows (https://git-scm.com/download/win) first." -} - +# -- Credentials --------------------------------------------------------------- function ConvertFrom-ShellQuoted { param([string]$Val) if ($null -eq $Val) { return $Val } @@ -411,51 +354,95 @@ function ConvertFrom-ShellQuoted { return $sb.ToString() } -# 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_]*)=(.+)$') { - $vals[$Matches[1]] = ConvertFrom-ShellQuoted $Matches[2].Trim() - } +function Test-EnvFileHasKey { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $false } + foreach ($line in (Get-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction SilentlyContinue)) { + if ($line -match '^\s*(?:export\s+)?ROGUE_API_KEY=["'']?[^"''\s]') { return $true } + } + return $false +} + +function Read-EnvFileValues { + param([string]$Path) + $vals = @{} + foreach ($line in (Get-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction SilentlyContinue)) { + if ($line -match '^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.+)$') { + $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 } + return $vals } -Load-ExistingCreds -if (-not $ApiKey) { +# The machine file is policy: only SYSTEM or Administrators (root off Windows) may +# own it, and nobody else may write it - the current user included, or a standard +# user with a write ACE could replace the key the MDM pushed. Inlined rather than +# taken from scripts/shared/env-file.ps1 because this installer is one downloaded file. +function Test-MachineEnvTrusted { + param([string]$Path) + try { + if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { + $info = & stat -Lc '%u %a' $Path 2>$null + if ($LASTEXITCODE -ne 0) { $info = & stat -Lf '%u %Lp' $Path 2>$null } + if ($LASTEXITCODE -ne 0 -or $info -notmatch '^(\d+) ([0-7]+)$') { return $false } + return ($Matches[1] -eq '0' -and ([Convert]::ToInt32($Matches[2], 8) -band 18) -eq 0) + } + if ($PSVersionTable.PSVersion.Major -eq 5) { + Import-Module (Join-Path $PSHOME 'Modules\Microsoft.PowerShell.Security\Microsoft.PowerShell.Security.psd1') -ErrorAction Stop + } + $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop + $admins = @('S-1-5-18', 'S-1-5-32-544') + if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -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 $admins) { return $false } + } + return $true + } catch { return $false } +} + +# Load existing settings from the user env file, as the dispatcher reads it. Not +# only a keyed file: a user file with no ROGUE_API_KEY can still carry the +# ROGUE_BASE_URL validation must use and the ROGUE_ACTOR_* identity +# Write-UserEnvFile would otherwise replace from the cascade. +function Load-ExistingCreds { + if (-not (Test-Path -LiteralPath $script:EnvFile -PathType Leaf)) { return } + $vals = Read-EnvFileValues $script:EnvFile + 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'] } +} + +function Read-ApiKey { if ($NonInteractive) { Warn2 'No API key set and running non-interactively - skipping key setup.' Warn2 'Run /rogue:setup inside Claude Code to connect your key later.' - } else { - $secure = Read-Host 'Rogue API key (rsk_...)' -AsSecureString - $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) - $ApiKey = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) - [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) - if (-not $ApiKey) { Die 'API key cannot be empty.' } + return } + $secure = Read-Host 'Rogue API key (rsk_...)' -AsSecureString + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) + # BSTR is UTF-16 everywhere; PtrToStringAuto decodes it as UTF-8 off Windows. + $script:ApiKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + if (-not $script:ApiKey) { Die 'API key cannot be empty.' } } # Actor identity: git config -> env fallbacks (mirrors actor.sh). -if (-not $Email) { try { $Email = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } -if (-not $Name) { try { $Name = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} } -if (-not $Email -and $env:CLAUDE_CODE_USER_EMAIL) { $Email = $env:CLAUDE_CODE_USER_EMAIL } -if (-not $Email) { $Email = "$env:USERNAME@$env:COMPUTERNAME" } -if (-not $Name) { $Name = $env:USERNAME } -Log "Actor: $Name <$Email>" +function Resolve-Actor { + if (-not $script:Email) { try { $script:Email = (& git config --global user.email 2>$null | Out-String).Trim() } catch {} } + if (-not $script:Name) { try { $script:Name = (& git config --global user.name 2>$null | Out-String).Trim() } catch {} } + if (-not $script:Email -and $env:CLAUDE_CODE_USER_EMAIL) { $script:Email = $env:CLAUDE_CODE_USER_EMAIL } + if (-not $script:Email) { $script:Email = "$env:USERNAME@$env:COMPUTERNAME" } + if (-not $script:Name) { $script:Name = $env:USERNAME } + Log "Actor: $script:Name <$script:Email>" +} # Validate the key AND register this install via /api/v1/hooks/status (the same # heartbeat the SessionStart hook calls), so the dashboard roster row is deduped. -if ($ApiKey) { +function Register-ApiKey { + param([string]$Key, [string]$Url, [string]$ActorEmail, [string]$MachineFile) Log 'Validating API key...' try { $hostName = $env:COMPUTERNAME; if (-not $hostName) { $hostName = 'unknown' } @@ -472,24 +459,33 @@ if ($ApiKey) { elseif ($hasGemini) { $scFamily = 'gemini'; $scAgent = 'gemini_cli' } elseif ($hasCopilot) { $scFamily = 'copilot'; $scAgent = 'github_copilot' } elseif ($hasKiro) { $scFamily = 'kiro'; $scAgent = 'kiro_cli' } - $body = @{ agent_family = $scFamily; agent = $scAgent; host = $hostName; actor_email = [string]$Email } | ConvertTo-Json -Compress + $body = @{ agent_family = $scFamily; agent = $scAgent; host = $hostName; actor_email = $ActorEmail } | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) - $resp = Invoke-WebRequest -Uri "$($BaseUrl.TrimEnd('/'))/api/v1/hooks/status" -Method Post ` - -Headers @{ 'x-rogue-api-key' = $ApiKey } -ContentType 'application/json' ` + $resp = Invoke-WebRequest -Uri "$($Url.TrimEnd('/'))/api/v1/hooks/status" -Method Post ` + -Headers @{ 'x-rogue-api-key' = $Key } -ContentType 'application/json' ` -Body $bytes -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop - if ($resp.StatusCode -eq 200) { Ok 'Key validated.' } else { Warn2 "Unexpected response (HTTP $($resp.StatusCode)) - saving without verification." } + if ($resp.StatusCode -eq 200) { Ok 'Key validated.'; return } + if ($MachineFile) { Warn2 "Unexpected response (HTTP $($resp.StatusCode)) validating the key in $MachineFile" } + else { Warn2 "Unexpected response (HTTP $($resp.StatusCode)) - saving without verification." } } catch { $code = $null if ($_.Exception.Response) { try { $code = [int]$_.Exception.Response.StatusCode } catch {} } if ($code -eq 401 -or $code -eq 403) { + # Nothing is saved on the machine path, so a bad key can only be reported. + if ($MachineFile) { Warn2 "The key in $MachineFile is invalid (HTTP $code) - every hook fails open until the MDM script pushes a valid one"; return } if ($NonInteractive) { Die "Invalid API key (HTTP $code)." } Warn2 "Invalid key (HTTP $code) - saving anyway. Verify it at https://app.rogue.security/settings/api-keys" + } elseif ($MachineFile) { + Warn2 "Could not reach $Url to validate the key in $MachineFile" } else { - Warn2 "Could not reach $BaseUrl to validate - saving without verification." + Warn2 "Could not reach $Url to validate - saving without verification." } } +} - function Format-EnvVal { param([string]$Val) return "'" + $Val.Replace("'", "'\''") + "'" } +function Format-EnvVal { param([string]$Val) return "'" + $Val.Replace("'", "'\''") + "'" } + +function Write-UserEnvFile { $managed = @('ROGUE_API_KEY', 'ROGUE_ACTOR_EMAIL', 'ROGUE_ACTOR_NAME') if ($BaseUrlExplicit) { $managed += 'ROGUE_BASE_URL' } foreach ($pair in @(@('ROGUE_API_KEY', $ApiKey), @('ROGUE_ACTOR_EMAIL', $Email), @@ -537,6 +533,103 @@ if ($ApiKey) { Ok "Credentials written to $EnvFile" } +# The dispatchers read a keyed machine env file alone, so on such a machine the +# user env file is never consulted: no prompt, no write, and a key passed to the +# installer goes nowhere. The key is still validated the way the hooks will use it. +function Use-MachineEnvFile { + $script:CredentialSource = $script:MachineEnvFile + Ok "Credentials come from the machine env file $script:MachineEnvFile - no API key prompt, $script:EnvFile not written." + if ($script:ApiKey -or $script:BaseUrlExplicit) { + Warn2 "The passed API key / base URL is ignored: $script:MachineEnvFile is read alone. Rotate it through the MDM script (docs/deployment.md, Rotating the API key)." + } + $vals = Read-EnvFileValues $script:MachineEnvFile + $url = if ($vals['ROGUE_BASE_URL']) { $vals['ROGUE_BASE_URL'] } else { $ROGUE_BASE_URL_DEFAULT } + if ($vals['ROGUE_ACTOR_EMAIL']) { $script:Email = $vals['ROGUE_ACTOR_EMAIL'] } + Resolve-Actor + Register-ApiKey -Key $vals['ROGUE_API_KEY'] -Url $url -ActorEmail $script:Email -MachineFile $script:MachineEnvFile +} + +function Configure-Credentials { + if (Test-EnvFileHasKey $script:MachineEnvFile) { + if (Test-MachineEnvTrusted $script:MachineEnvFile) { Use-MachineEnvFile; return } + # Kiro and the log shipper skip an untrusted machine file, so the user file + # must still be written for them. + Warn2 "$script:MachineEnvFile holds ROGUE_API_KEY but is not owned by SYSTEM/Administrators or is writable by others - Kiro and log shipping ignore it; configuring $script:EnvFile instead." + } + $script:CredentialSource = $script:EnvFile + Load-ExistingCreds + if (-not $script:ApiKey) { Read-ApiKey } + Resolve-Actor + if (-not $script:ApiKey) { return } + Register-ApiKey -Key $script:ApiKey -Url $script:BaseUrl -ActorEmail $script:Email + Write-UserEnvFile +} + + +# Test seam: load only the functions above (tests/test_install_kiro_ps1.ps1, +# tests/test_install_env_ps1.ps1). +if ($env:ROGUE_INSTALL_LIB_ONLY) { return } + +try { + [Net.ServicePointManager]::SecurityProtocol = ` + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +} catch {} + +Write-Host "" +Write-Host "Rogue Security (Windows)" -ForegroundColor Cyan + +# Agent selection. -Claude/-Codex/-Cursor pick an explicit set; with none, auto-detect +# every supported agent. claude/codex ship a CLI on PATH; Cursor's `cursor` command is +# opt-in, so detection also accepts %USERPROFILE%\.cursor. An explicitly selected CLI +# agent still needs its binary; Cursor is a plain file copy, so it installs regardless. +# Antigravity has no `antigravity` binary on PATH — detect the `agy` CLI or its data +# dirs under %USERPROFILE%\.gemini (IDE and/or manual-CLI installs). +$explicit = $Claude -or $Codex -or $Cursor -or $Gemini -or $Copilot -or $Antigravity -or $Kiro +if ($explicit) { + $hasClaude = [bool]$Claude + $hasCodex = [bool]$Codex + $hasCursor = [bool]$Cursor + $hasGemini = [bool]$Gemini + $hasCopilot = [bool]$Copilot + $hasAntigravity = [bool]$Antigravity + $hasKiro = [bool]$Kiro + if ($hasClaude -and -not (Get-Command claude -ErrorAction SilentlyContinue)) { + Die "-Claude requested but the 'claude' CLI is not on PATH. Install Claude Code (https://claude.com/code) first." + } + if ($hasCodex -and -not (Get-Command codex -ErrorAction SilentlyContinue)) { + Die "-Codex requested but the 'codex' CLI is not on PATH. Install OpenAI Codex first." + } + if ($hasGemini -and -not (Get-Command gemini -ErrorAction SilentlyContinue)) { + Die "-Gemini requested but the 'gemini' CLI is not on PATH. Install Gemini CLI (https://geminicli.com) first." + } + if ($hasCopilot -and -not (Get-Command copilot -ErrorAction SilentlyContinue)) { + Die "-Copilot requested but the 'copilot' CLI is not on PATH. Install GitHub Copilot CLI (https://github.com/github/copilot-cli) first." + } + if ($hasAntigravity -and -not ((Get-Command agy -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:USERPROFILE '.gemini\antigravity*')))) { + Die "-Antigravity requested but no Antigravity install was detected (looked for: agy CLI, %USERPROFILE%\.gemini\antigravity*). Install Google Antigravity first." + } + if ($hasKiro -and -not (Test-KiroInstalled)) { + Die "-Kiro requested but no Kiro install was detected (looked for: kiro-cli, %LOCALAPPDATA%\Programs\Kiro, %USERPROFILE%\.kiro). Install Kiro (https://kiro.dev) first." + } +} else { + $hasClaude = [bool](Get-Command claude -ErrorAction SilentlyContinue) + $hasCodex = [bool](Get-Command codex -ErrorAction SilentlyContinue) + $hasCursor = [bool](Get-Command cursor -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:USERPROFILE '.cursor')) + $hasGemini = [bool](Get-Command gemini -ErrorAction SilentlyContinue) + $hasCopilot = [bool](Get-Command copilot -ErrorAction SilentlyContinue) + $hasAntigravity = [bool](Get-Command agy -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:USERPROFILE '.gemini\antigravity*')) + $hasKiro = Test-KiroInstalled + if (-not ($hasClaude -or $hasCodex -or $hasCursor -or $hasGemini -or $hasCopilot -or $hasAntigravity -or $hasKiro)) { + Die "No supported coding agent found (looked for: claude, codex, cursor, gemini, copilot, antigravity, kiro). Install Claude Code (https://claude.com/code), OpenAI Codex, Cursor (https://cursor.com), Gemini CLI (https://geminicli.com), GitHub Copilot CLI (https://github.com/github/copilot-cli), Google Antigravity, or Kiro (https://kiro.dev) first." + } +} +# Claude shells out to git to clone the marketplace; git is required only for it. +if ($hasClaude -and -not (Get-Command git -ErrorAction SilentlyContinue)) { + Die "git not found. Install Git for Windows (https://git-scm.com/download/win) first." +} + +Configure-Credentials + # Install through each agent's CLI marketplace (cross-platform; same monorepo for # both — Claude reads .claude-plugin/marketplace.json, Codex reads # .agents/plugins/marketplace.json; marketplace `rogue-marketplace` + plugin @@ -830,7 +923,7 @@ Write-Host @" v Rogue Security installed. - Credentials: $EnvFile + Credentials: $CredentialSource Next steps: 1. Fully quit and reopen each agent (hooks load credentials at session start). diff --git a/install.sh b/install.sh index 681cf3f..30b188d 100755 --- a/install.sh +++ b/install.sh @@ -14,7 +14,8 @@ # # Env knobs: # ROGUE_NON_INTERACTIVE=1 no prompts (used by auto-update.sh re-invocation) -# ROGUE_API_KEY=... pre-seed the API key (skips the prompt) +# ROGUE_API_KEY=... pre-seed the API key (skips the prompt; ignored when +# /etc/rogue/env already holds one — that file is read alone) # ROGUE_ACTOR_EMAIL=... pre-seed actor identity # ROGUE_ACTOR_NAME=... # ROGUE_PLUGIN_REPO=... marketplace source (default below) @@ -47,18 +48,26 @@ # set -u +# A base URL that ends in a slash composes "//api/v1/..." on every request, which +# the API does not route. Applied wherever the value can enter. +trim_base_url() { + while [ "${ROGUE_BASE_URL%/}" != "$ROGUE_BASE_URL" ]; do ROGUE_BASE_URL="${ROGUE_BASE_URL%/}"; done +} + # ── Config ────────────────────────────────────────────────────────────────── ROGUE_PLUGIN_REPO="${ROGUE_PLUGIN_REPO:-qualifire-dev/rogue-plugins}" ROGUE_BASE_URL_DEFAULT="https://api.rogue.security" BASE_URL_EXPLICIT=0 [ -z "${ROGUE_BASE_URL:-}" ] || BASE_URL_EXPLICIT=1 ROGUE_BASE_URL="${ROGUE_BASE_URL:-$ROGUE_BASE_URL_DEFAULT}" +trim_base_url MARKETPLACE_NAME="rogue-marketplace" 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="$HOME/.rogue-env" +MACHINE_ENV_FILE="/etc/rogue/env" NON_INTERACTIVE="${ROGUE_NON_INTERACTIVE:-0}" # Explicit agent selection via --claude/--codex/--cursor. Empty = auto-detect all. @@ -676,37 +685,102 @@ key_hint() { # key_hint if [ "${#k}" -le 8 ]; then printf '%s' "$k"; else printf '%s…' "${k:0:8}"; fi } +env_file_has_key() { # env_file_has_key + [ -r "$1" ] && grep -Eq "^[[:space:]]*(export[[:space:]]+)?ROGUE_API_KEY=[\"']?[^\"'[:space:]]" "$1" +} + +env_file_value() { # env_file_value — first assignment, unquoted, without sourcing + sed -nE "s/^[[:space:]]*(export[[:space:]]+)?$2=[\"']?([^\"'[:space:]]+).*/\2/p" "$1" | head -1 +} + +# Owner uid and octal mode, or nothing when stat cannot say. +file_owner_mode() { # file_owner_mode + stat -Lc '%u %a' "$1" 2>/dev/null || stat -Lf '%u %Lp' "$1" 2>/dev/null +} + +# Same rule as rogue_env_is_trusted (scripts/shared/env-file.sh) for the system +# path, inlined because this installer is one downloaded file: root-owned, and +# neither group nor other may write. +machine_env_is_trusted() { # machine_env_is_trusted + local info owner mode + info="$(file_owner_mode "$1")" || return 1 + owner="${info%% *}"; mode="${info#* }" + case "$owner:$mode" in *[!0-9:]*|:*) return 1 ;; esac + [ "$owner" = 0 ] && [ "$((0$mode & 022))" = 0 ] +} + +# Resolve actor defaults (same cascade as plugins/rogue/scripts/actor.sh) so key +# validation can register the roster row under the real email, deduped with the +# later SessionStart heartbeats. Explicit flag/env beats on-disk. +resolve_actor_defaults() { # resolve_actor_defaults → DEF_EMAIL, DEF_NAME + DEF_EMAIL="${1:-${ROGUE_ACTOR_EMAIL:-$(git config --global user.email 2>/dev/null)}}" + DEF_NAME="${2:-${ROGUE_ACTOR_NAME:-$(git config --global user.name 2>/dev/null)}}" + [ -n "$DEF_EMAIL" ] || DEF_EMAIL="${CLAUDE_CODE_USER_EMAIL:-}" + [ -n "$DEF_NAME" ] || { DEF_NAME="${CLAUDE_CODE_USER_EMAIL:-}"; DEF_NAME="${DEF_NAME%@*}"; } + [ -n "$DEF_EMAIL" ] || DEF_EMAIL="$(hostname 2>/dev/null)" + [ -n "$DEF_NAME" ] || DEF_NAME="$(whoami 2>/dev/null)" +} + +# Validate the machine file's key (and register the roster row) the way the +# hooks will use it: its own base URL, else the default; its own actor email, +# else the cascade. Nothing is saved, so a bad key can only be reported. +validate_machine_env_key() { + local key url code + key="$(env_file_value "$MACHINE_ENV_FILE" ROGUE_API_KEY)" + url="$(env_file_value "$MACHINE_ENV_FILE" ROGUE_BASE_URL)" + ROGUE_BASE_URL="${url:-$ROGUE_BASE_URL_DEFAULT}" + trim_base_url + resolve_actor_defaults "$(env_file_value "$MACHINE_ENV_FILE" ROGUE_ACTOR_EMAIL)" "" + code="$(status_check "$key" "$DEF_EMAIL")" + case "$code" in + 200) ok "Key validated${STATUS_ORG:+ — org: $STATUS_ORG}" ;; + 401|403) warn "The key in $MACHINE_ENV_FILE is invalid (HTTP $code) — every hook fails open until the MDM script pushes a valid one" ;; + '') warn "Could not reach $ROGUE_BASE_URL to validate the key in $MACHINE_ENV_FILE" ;; + *) warn "Unexpected response (HTTP $code) validating the key in $MACHINE_ENV_FILE" ;; + esac +} + +# The hooks read a keyed machine env file alone, so a user env file written +# here would never be consulted, and a key passed to the installer goes nowhere. +use_machine_env_file() { + ok "Credentials come from the machine env file ${C_DIM}$MACHINE_ENV_FILE${C_RESET} — no API key prompt, $ENV_FILE not written" + if [ -n "${ROGUE_API_KEY:-}" ] || [ "$BASE_URL_EXPLICIT" = "1" ]; then + warn "The passed API key / base URL is ignored: $MACHINE_ENV_FILE is read alone. Rotate it through the MDM script (docs/deployment.md, Rotating the API key)." + fi + validate_machine_env_key +} + configure_credentials() { + if env_file_has_key "$MACHINE_ENV_FILE"; then + if machine_env_is_trusted "$MACHINE_ENV_FILE"; then + use_machine_env_file + return + fi + # Kiro and the log shipper skip an untrusted machine file, so the user file + # must still be written for them. + warn "$MACHINE_ENV_FILE holds ROGUE_API_KEY but is not root-owned with mode 644 or stricter (owner/mode: $(file_owner_mode "$MACHINE_ENV_FILE")) — Kiro and log shipping ignore it; configuring $ENV_FILE instead" + fi + # Capture explicit input (CLI flags / env vars) BEFORE sourcing the on-disk - # files — otherwise a stored key would clobber a key the caller passed to + # file — otherwise a stored key would clobber a key the caller passed to # rotate it. Explicit user intent wins; on-disk is the fallback. local flag_key="${ROGUE_API_KEY:-}" local flag_email="${ROGUE_ACTOR_EMAIL:-}" local flag_name="${ROGUE_ACTOR_NAME:-}" local flag_base_url="$ROGUE_BASE_URL" - # 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 + # Whole file, not only a keyed one: a user file with no ROGUE_API_KEY can still + # carry the ROGUE_BASE_URL validation must use and the ROGUE_ACTOR_* identity + # write_env_file would otherwise replace from the cascade. + [ ! -r "$ENV_FILE" ] || . "$ENV_FILE" [ "$BASE_URL_EXPLICIT" = "1" ] && ROGUE_BASE_URL="$flag_base_url" + trim_base_url local cur_key="${flag_key:-${ROGUE_API_KEY:-}}" - # Resolve actor defaults up front (same cascade as plugins/rogue/scripts/actor.sh) - # so key validation can register the roster row under the real email, deduped - # with the later SessionStart heartbeats. Explicit flag/env beats on-disk. - local def_email def_name - def_email="${flag_email:-${ROGUE_ACTOR_EMAIL:-$(git config --global user.email 2>/dev/null)}}" - def_name="${flag_name:-${ROGUE_ACTOR_NAME:-$(git config --global user.name 2>/dev/null)}}" - [ -n "$def_email" ] || def_email="${CLAUDE_CODE_USER_EMAIL:-}" - [ -n "$def_name" ] || { def_name="${CLAUDE_CODE_USER_EMAIL:-}"; def_name="${def_name%@*}"; } - [ -n "$def_email" ] || def_email="$(hostname 2>/dev/null)" - [ -n "$def_name" ] || def_name="$(whoami 2>/dev/null)" + resolve_actor_defaults "$flag_email" "$flag_name" + local def_email="$DEF_EMAIL" def_name="$DEF_NAME" # Non-interactive: persist whatever key is in scope (env-passed or on-disk), # filling actor identity from the resolved cascade. A key passed only via the @@ -993,7 +1067,7 @@ parse_args() { --actor-email) [ -n "$val" ] || { val="$2"; shift; }; ROGUE_ACTOR_EMAIL="$val" ;; --actor-name) [ -n "$val" ] || { val="$2"; shift; }; ROGUE_ACTOR_NAME="$val" ;; --plugin-repo) [ -n "$val" ] || { val="$2"; shift; }; ROGUE_PLUGIN_REPO="$val" ;; - --base-url) [ -n "$val" ] || { val="$2"; shift; }; ROGUE_BASE_URL="$val"; BASE_URL_EXPLICIT=1 ;; + --base-url) [ -n "$val" ] || { val="$2"; shift; }; ROGUE_BASE_URL="$val"; trim_base_url; BASE_URL_EXPLICIT=1 ;; --claude) WANT="$WANT claude" ;; --codex) WANT="$WANT codex" ;; --cursor) WANT="$WANT cursor" ;; @@ -1053,7 +1127,7 @@ main() { [ -n "$agents" ] || die "No supported coding agent found (looked for: claude, codex, cursor, gemini, copilot, antigravity, kiro). Install Claude Code (https://claude.com/code), OpenAI Codex, Cursor (https://cursor.com), Gemini CLI (https://geminicli.com), GitHub Copilot CLI (https://github.com/github/copilot-cli), Google Antigravity, or Kiro (https://kiro.dev) first." fi - # Credentials once — every plugin reads the shared ~/.rogue-env. + # Credentials once — every plugin reads the machine env file, else the shared ~/.rogue-env. configure_credentials for a in $agents; do diff --git a/tests/test_install_env_ps1.ps1 b/tests/test_install_env_ps1.ps1 new file mode 100644 index 0000000..49ba73b --- /dev/null +++ b/tests/test_install_env_ps1.ps1 @@ -0,0 +1,254 @@ +#!/usr/bin/env pwsh +# tests/test_install_env_ps1.ps1 - install.ps1 and the machine env file, in +# lockstep with tests/test_install_env_sh.sh. +# +# A keyed C:\ProgramData\rogue\env owned by SYSTEM/Administrators is read alone +# by every dispatcher, so on such a machine the installer prompts for nothing and +# writes no %USERPROFILE%\.rogue-env; it names the file in use, validates its key +# and installs the plugins as before. With the machine file absent, present +# without ROGUE_API_KEY, or keyed but writable by others (Kiro and the log +# shipper skip it), the prompt and the user env file write are as they were. +# install.ps1 runs from a COPY whose machine path literal points into a sandbox: +# once end to end (-Cursor, Invoke-WebRequest and the key validation stubbed), +# otherwise Configure-Credentials through the ROGUE_INSTALL_LIB_ONLY seam with +# Read-Host counting its calls. Off Windows a `stat` shim on PATH reports the +# sandbox file as root-owned; on Windows its owner is set to Administrators. + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$repo = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($here, '..')) + +$work = Join-Path ([System.IO.Path]::GetTempPath()) ("rogue-env-install-" + [System.IO.Path]::GetRandomFileName()) +$bin = Join-Path $work 'bin' +New-Item -ItemType Directory -Path $bin -Force | Out-Null + +$MachineEnvFile = Join-Path $work 'machine-env' +$EnvFile = Join-Path $work '.rogue-env' +$installer = Join-Path $work 'install.ps1' +$src = [System.IO.File]::ReadAllText((Join-Path $repo 'install.ps1')) +$redirected = $src.Replace("`$MachineEnvFile = 'C:\ProgramData\rogue\env'", "`$MachineEnvFile = '$MachineEnvFile'") +if ($redirected -eq $src) { Write-Host 'the machine path was not redirected'; exit 1 } +[System.IO.File]::WriteAllText($installer, $redirected) + +$unix = $PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows +$prevKey = $env:ROGUE_API_KEY +$prevProfile = $env:USERPROFILE +$prevPath = $env:PATH +$env:ROGUE_API_KEY = $null +$env:USERPROFILE = $work +if ($unix) { + $realStat = (Get-Command stat -CommandType Application | Select-Object -First 1).Source + [System.IO.File]::WriteAllText((Join-Path $bin 'stat'), @" +#!/usr/bin/env bash +for a in "`$@"; do + if [ "`$a" = "$MachineEnvFile" ]; then + if "$realStat" --version >/dev/null 2>&1; then mode="`$("$realStat" -c %a "`$a")"; else mode="`$("$realStat" -f %Lp "`$a")"; fi + printf '%s %s\n' "`${ROGUE_TEST_MACHINE_OWNER:-0}" "`$mode"; exit 0 + fi +done +exec "$realStat" "`$@" +"@) + & chmod +x (Join-Path $bin 'stat') + $env:PATH = "$bin$([System.IO.Path]::PathSeparator)$env:PATH" +} + +$env:ROGUE_INSTALL_LIB_ONLY = '1' +. $installer +$env:ROGUE_INSTALL_LIB_ONLY = $null +$ErrorActionPreference = 'Stop' +$Email = 'tester@example.com'; $Name = 'Tester' + +# Machine file fixtures: trusted = owned by root/Administrators, writable by no +# one else; untrusted = the same file with a write grant for the CURRENT USER, +# which the installer must refuse - a standard user who can rewrite the file can +# replace the key the MDM pushed. The Windows ACL is protected and rebuilt from +# nothing, so the trusted case cannot inherit a write grant from the temp +# directory and pass for the wrong reason. Recreate rather than overwrite: the +# previous call leaves an ACL this process may not be able to write through. +function Set-MachineFile { + param([string]$Content, [switch]$Untrusted) + Remove-Item -LiteralPath $MachineEnvFile -Force -ErrorAction SilentlyContinue + [System.IO.File]::WriteAllText($MachineEnvFile, $Content) + if ($unix) { + & chmod ($(if ($Untrusted) { '666' } else { '644' })) $MachineEnvFile + return + } + $admins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544') + $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $rights = 'Read' + if ($Untrusted) { $rights = 'Read, Write' } + $acl = Get-Acl -LiteralPath $MachineEnvFile + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($admins) + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', 'Allow'))) + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($me, $rights, 'Allow'))) + Set-Acl -LiteralPath $MachineEnvFile -AclObject $acl +} + +class FakeHttpError : System.Exception { + [object]$Response + FakeHttpError([int]$code) : base("HTTP $code") { $this.Response = [pscustomobject]@{ StatusCode = $code } } +} +# Env vars, not script variables: the stubs also run from the end-to-end child +# script, whose $script: scope is its own. +$env:ROGUE_TEST_HTTP_CODE = '200' +$script:prompts = 0 +function Read-Host { + param([Parameter(Position = 0)][string]$Prompt, [switch]$AsSecureString) + $script:prompts++ + return (ConvertTo-SecureString 'typed-key' -AsPlainText -Force) +} +# `-OutFile` is the Cursor tarball download; anything else is the validation POST. +function Invoke-WebRequest { + $i = [array]::IndexOf($args, '-OutFile') + if ($i -ge 0) { Copy-Item -LiteralPath $env:ROGUE_TEST_TARBALL -Destination $args[$i + 1]; return } + if ($env:ROGUE_TEST_HTTP_CODE -ne '200') { throw [FakeHttpError]::new([int]$env:ROGUE_TEST_HTTP_CODE) } + return [pscustomobject]@{ StatusCode = 200 } +} + +$fails = 0 +function Assert-Eq { + param($Got, $Expected, [string]$Label) + if ([string]$Got -ceq [string]$Expected) { Write-Host " ok: $Label" } + else { Write-Host "FAIL [$Label]: got <$Got>, expected <$Expected>"; $script:fails++ } +} +function Count-Matches { param([string]$Text, [string]$Needle) return ([regex]::Matches($Text, [regex]::Escape($Needle))).Count } + +# Run-Configure -> the installer's console output as one string. +function Run-Configure { + param([string]$Key) + $script:ApiKey = $Key + $script:CredentialSource = $null + $script:prompts = 0 + Remove-Item -LiteralPath $EnvFile -Force -ErrorAction SilentlyContinue + return (Configure-Credentials 6>&1 | Out-String -Width 4096) +} +function Get-WrittenKey { + if (-not (Test-Path -LiteralPath $EnvFile)) { return '' } + foreach ($line in (Get-Content -LiteralPath $EnvFile)) { + if ($line -match "^export ROGUE_API_KEY='(.*)'$") { return $Matches[1] } + } + return '' +} + +# -- 1. Trusted machine env file with a key: no prompt, no user env file, key validated +Set-MachineFile "export ROGUE_API_KEY='machine-key'`nexport ROGUE_ACTOR_EMAIL='mdm@example.com'`n" +$NonInteractive = $true +$out = Run-Configure '' +Assert-Eq $script:prompts 0 'keyed machine file: no credential prompt' +Assert-Eq (Test-Path -LiteralPath $EnvFile) $false 'keyed machine file: no user env file' +Assert-Eq (Count-Matches $out "machine env file $MachineEnvFile") 1 'keyed machine file: output names the machine file once' +Assert-Eq (Count-Matches $out 'Key validated') 1 'keyed machine file: the machine key is validated' +Assert-Eq (Count-Matches $out 'is ignored') 0 'keyed machine file: no ignored-key warning' +Assert-Eq $CredentialSource $MachineEnvFile 'keyed machine file: summary points at the machine file' + +$NonInteractive = $false +$out = Run-Configure '' +Assert-Eq $script:prompts 0 'keyed machine file, interactive: no prompt' +Assert-Eq (Test-Path -LiteralPath $EnvFile) $false 'keyed machine file, interactive: no user env file' + +$out = Run-Configure 'passed-key' +Assert-Eq (Test-Path -LiteralPath $EnvFile) $false 'keyed machine file + passed key: no user env file' +Assert-Eq (Count-Matches $out "API key / base URL is ignored: $MachineEnvFile") 1 'keyed machine file + passed key: warns that the key is ignored' + +$NonInteractive = $true +$env:ROGUE_TEST_HTTP_CODE = '401' +$out = Run-Configure '' +$env:ROGUE_TEST_HTTP_CODE = '200' +Assert-Eq (Count-Matches $out "key in $MachineEnvFile is invalid (HTTP 401)") 1 'keyed machine file, key rejected: warning names the file' +Assert-Eq (Test-Path -LiteralPath $EnvFile) $false 'keyed machine file, key rejected: no user env file' + +# End to end: the credential skip does not end the installer; the Cursor section +# runs and the summary prints. The plugin files themselves land only on Windows, +# whose backslash paths the Cursor block is written for. +$stage = [System.IO.Path]::Combine($work, 'stage', 'rogue-plugin-cursor', 'plugins') +New-Item -ItemType Directory -Path $stage -Force | Out-Null +Copy-Item -Recurse ([System.IO.Path]::Combine($repo, 'plugins', 'cursor')) ([System.IO.Path]::Combine($stage, 'cursor')) +$env:ROGUE_TEST_TARBALL = Join-Path $work 'rogue-plugin-cursor.tar.gz' +& tar -czf $env:ROGUE_TEST_TARBALL -C (Join-Path $work 'stage') rogue-plugin-cursor +if ($LASTEXITCODE -ne 0) { Write-Host 'could not build the Cursor tarball'; exit 1 } +$out = & $installer -Cursor -NonInteractive 6>&1 | Out-String -Width 4096 +Assert-Eq (Count-Matches $out "machine env file $MachineEnvFile") 1 'end to end: output names the machine file once' +Assert-Eq (Count-Matches $out 'Key validated') 1 'end to end: the machine key is validated' +Assert-Eq (Test-Path -LiteralPath $EnvFile) $false 'end to end: no user env file' +Assert-Eq ($out -match 'Rogue Security - Cursor') $true 'end to end: the Cursor install runs after the credential skip' +Assert-Eq ($out -match 'Credentials:\s+' + [regex]::Escape($MachineEnvFile)) $true 'end to end: summary lists the machine file' +if (-not $unix) { + Assert-Eq (Test-Path -LiteralPath (Join-Path $work '.cursor\plugins\local\rogue\.cursor-plugin\plugin.json')) $true 'end to end: Cursor plugin installed' +} + +# -- 2. No machine env file: unchanged --------------------------------------------- +Remove-Item -LiteralPath $MachineEnvFile -Force +$NonInteractive = $true +$out = Run-Configure 'passed-key' +Assert-Eq $script:prompts 0 'no machine file, non-interactive: no prompt' +Assert-Eq (Get-WrittenKey) 'passed-key' 'no machine file, non-interactive: passed key written' +Assert-Eq ($out -match 'machine env file') $false 'no machine file: output does not name a machine file' +Assert-Eq $CredentialSource $EnvFile 'no machine file: summary points at the user file' + +$NonInteractive = $false +$out = Run-Configure '' +Assert-Eq $script:prompts 1 'no machine file, interactive: prompts once' +Assert-Eq (Get-WrittenKey) 'typed-key' 'no machine file, interactive: typed key written' + +# -- 3. Machine env file without a key: unchanged, as in 2 -------------------------- +Set-MachineFile "export ROGUE_ACTOR_EMAIL='mdm@example.com'`n# ROGUE_API_KEY='commented-out'`nexport ROGUE_API_KEY=`n" +$NonInteractive = $true +$out = Run-Configure 'passed-key' +Assert-Eq $script:prompts 0 'keyless machine file, non-interactive: no prompt' +Assert-Eq (Get-WrittenKey) 'passed-key' 'keyless machine file, non-interactive: passed key written' +Assert-Eq ($out -match 'machine env file') $false 'keyless machine file: output does not name a machine file' + +$NonInteractive = $false +$out = Run-Configure '' +Assert-Eq $script:prompts 1 'keyless machine file, interactive: prompts once' +Assert-Eq (Get-WrittenKey) 'typed-key' 'keyless machine file, interactive: typed key written' + +# -- 4. Keyed machine env file the current user can write: warned, then as in 2 ----- +Set-MachineFile "export ROGUE_API_KEY='machine-key'`n" -Untrusted +$NonInteractive = $true +$out = Run-Configure 'passed-key' +Assert-Eq (Count-Matches $out "$MachineEnvFile holds ROGUE_API_KEY but is not owned by SYSTEM/Administrators or is writable by others") 1 'writable machine file: warning names the file' +Assert-Eq (Get-WrittenKey) 'passed-key' 'writable machine file, non-interactive: passed key written' +Assert-Eq ($out -match 'Credentials come from the machine env file') $false 'writable machine file: not named as the credential source' + +$NonInteractive = $false +$out = Run-Configure '' +Assert-Eq $script:prompts 1 'writable machine file, interactive: prompts once' +Assert-Eq (Get-WrittenKey) 'typed-key' 'writable machine file, interactive: typed key written' + +if ($unix) { + Set-MachineFile "export ROGUE_API_KEY='machine-key'`n" + $env:ROGUE_TEST_MACHINE_OWNER = (& id -u) + $NonInteractive = $true + $out = Run-Configure 'passed-key' + $env:ROGUE_TEST_MACHINE_OWNER = $null + Assert-Eq (Count-Matches $out "$MachineEnvFile holds ROGUE_API_KEY") 1 'user-owned machine file: warning names the file' + Assert-Eq (Get-WrittenKey) 'passed-key' 'user-owned machine file: passed key written' +} + +# -- 5. User env file with no key: its base URL and identity still apply ---------- +Remove-Item -LiteralPath $MachineEnvFile -Force -ErrorAction SilentlyContinue +[System.IO.File]::WriteAllText($EnvFile, + "export ROGUE_BASE_URL='https://api.example.invalid'`nexport ROGUE_ACTOR_EMAIL='stored@example.com'`nexport ROGUE_ACTOR_NAME='Stored Name'`n") +$Email = ''; $Name = '' +$BaseUrl = $ROGUE_BASE_URL_DEFAULT; $BaseUrlExplicit = $false +$script:ApiKey = 'passed-key' +$script:CredentialSource = $null +$script:prompts = 0 +$NonInteractive = $true +$out = Configure-Credentials 6>&1 | Out-String -Width 4096 +Assert-Eq $script:prompts 0 'keyless user file: no credential prompt' +Assert-Eq (Get-WrittenKey) 'passed-key' 'keyless user file: the passed key wins' +Assert-Eq $script:Email 'stored@example.com' 'keyless user file: stored actor email kept' +Assert-Eq $script:Name 'Stored Name' 'keyless user file: stored actor name kept' +Assert-Eq $script:BaseUrl 'https://api.example.invalid' 'keyless user file: stored base URL adopted' + +$env:ROGUE_API_KEY = $prevKey +$env:USERPROFILE = $prevProfile +$env:PATH = $prevPath +$env:ROGUE_TEST_HTTP_CODE = $null +$env:ROGUE_TEST_TARBALL = $null +Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue +Write-Host '' +if ($fails -eq 0) { Write-Host 'all install env-file tests passed'; exit 0 } +Write-Host "$fails FAILED"; exit 1 diff --git a/tests/test_install_env_sh.sh b/tests/test_install_env_sh.sh new file mode 100755 index 0000000..f12ddfb --- /dev/null +++ b/tests/test_install_env_sh.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# tests/test_install_env_sh.sh — install.sh and the machine env file. +# +# A keyed, root-owned /etc/rogue/env is read alone by every hook, so on such a +# machine the installer prompts for nothing and writes no ~/.rogue-env; it prints +# which file is in use, validates the file's key, and installs the plugins as +# before. With the machine file absent, present without ROGUE_API_KEY, or keyed +# but not root-owned/mode 644 (Kiro and the log shipper skip it), the prompt and +# the user env file write are as they were. install.sh runs from a COPY whose +# /etc/rogue/env literal points into the sandbox, and a `stat` shim on PATH +# reports that file as root-owned (the only way to stage the machine candidate +# without root). +# +# bash tests/test_install_env_sh.sh +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +fails=0 + +ok() { echo " ok: $1"; } +bad() { echo "FAIL [$1]: $2"; fails=$((fails + 1)); } +check() { #